@agent-native/core 0.145.0 → 0.145.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 (49) hide show
  1. package/dist/agent/run-manager.d.ts +44 -1
  2. package/dist/agent/run-manager.js +70 -8
  3. package/dist/chat-threads/store.js +6 -4
  4. package/dist/client/use-db-sync.js +9 -1
  5. package/dist/collab/awareness.d.ts +2 -2
  6. package/dist/collab/routes.d.ts +1 -1
  7. package/dist/collab/struct-routes.d.ts +1 -1
  8. package/dist/db/ddl-guard.d.ts +8 -0
  9. package/dist/db/ddl-guard.js +148 -0
  10. package/dist/deploy/build.d.ts +36 -0
  11. package/dist/deploy/build.js +78 -5
  12. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  13. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  14. package/dist/notifications/routes.d.ts +1 -1
  15. package/dist/observability/routes.d.ts +3 -3
  16. package/dist/org/accept-pending.js +2 -2
  17. package/dist/org/auth-policy.js +4 -0
  18. package/dist/org/auto-join-domain.d.ts +9 -0
  19. package/dist/org/auto-join-domain.js +53 -4
  20. package/dist/org/context.js +12 -3
  21. package/dist/org/handlers.js +17 -5
  22. package/dist/org/request-org-cache.d.ts +24 -2
  23. package/dist/org/request-org-cache.js +57 -2
  24. package/dist/progress/routes.d.ts +1 -1
  25. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  26. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  27. package/dist/resources/handlers.d.ts +1 -1
  28. package/dist/resources/store.d.ts +1 -1
  29. package/dist/resources/store.js +96 -7
  30. package/dist/secrets/routes.d.ts +6 -6
  31. package/dist/server/agent-chat-plugin.js +16 -4
  32. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  33. package/dist/server/auth.d.ts +3 -0
  34. package/dist/server/auth.js +22 -1
  35. package/dist/server/collab-plugin.js +10 -4
  36. package/dist/server/core-routes-plugin.js +3 -0
  37. package/dist/server/session-email-cache.d.ts +14 -0
  38. package/dist/server/session-email-cache.js +48 -0
  39. package/dist/server/sweep-runtime.d.ts +22 -0
  40. package/dist/server/sweep-runtime.js +28 -0
  41. package/dist/settings/store.js +63 -7
  42. package/dist/shared/ttl-cache.d.ts +59 -0
  43. package/dist/shared/ttl-cache.js +43 -0
  44. package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
  45. package/dist/sharing/actions/list-resource-shares.js +4 -1
  46. package/dist/triggers/condition-evaluator.js +11 -27
  47. package/docs/content/deployment.mdx +48 -0
  48. package/docs/content/environment-variables.mdx +5 -0
  49. package/package.json +3 -3
@@ -4,6 +4,7 @@ import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db
4
4
  import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
5
5
  import { canUseLocalWorkspaceResourcePath, deleteLocalWorkspaceResource, isLocalWorkspaceResourceId, isLocalWorkspaceResourcesEnabled, listLocalWorkspaceResources, localWorkspaceResourcePathFromId, readLocalWorkspaceResource, writeLocalWorkspaceResource, } from "../local-artifacts/index.js";
6
6
  import { getRequestOrgId, getRequestUserEmail, } from "../server/request-context.js";
7
+ import { getSetting, putSetting, } from "../settings/store.js";
7
8
  import { emitResourceChange, emitResourceDelete } from "./emitter.js";
8
9
  export const SHARED_OWNER = "__shared__";
9
10
  export const WORKSPACE_OWNER = "__workspace__";
@@ -570,14 +571,34 @@ async function grantedWorkspaceResourceByPath(path, options) {
570
571
  return null;
571
572
  }
572
573
  }
573
- async function cleanupExpiredAgentScratchResources(client) {
574
+ /**
575
+ * Expire agent scratch resources, WITHOUT blocking the read that triggered it.
576
+ *
577
+ * This is garbage collection, not part of any read's answer: a caller asking for
578
+ * one resource does not need yesterday's expired scratch rows gone first. Awaiting
579
+ * it made every read path issue a `DELETE` before its own `SELECT` — double the
580
+ * round trips, unservable by a replica, taking row locks inside a request a user
581
+ * is waiting on, and (since nothing caught it) able to fail the read outright.
582
+ *
583
+ * The throttle is a module-scope timestamp, so it starts at 0 in a fresh isolate
584
+ * and the first resource read after every cold start still triggers one sweep.
585
+ * That is why the index below exists, and why this must not be awaited.
586
+ */
587
+ function scheduleExpiredAgentScratchCleanup(client) {
574
588
  const now = Date.now();
575
589
  if (now - _lastScratchCleanupAt < SCRATCH_CLEANUP_INTERVAL_MS)
576
590
  return;
577
591
  _lastScratchCleanupAt = now;
578
- await client.execute({
592
+ void client
593
+ .execute({
579
594
  sql: `DELETE FROM resources WHERE visibility = ? AND expires_at IS NOT NULL AND expires_at <= ?`,
580
595
  args: ["agent_scratch", now],
596
+ })
597
+ .catch((err) => {
598
+ // Say so rather than swallowing: scratch rows accumulating forever is a
599
+ // slow leak nobody would otherwise notice.
600
+ // coercion-ok: failed GC degrades storage over time, never read correctness
601
+ console.warn("[resources] expired agent-scratch cleanup failed; will retry on a later read:", err?.message ?? err);
581
602
  });
582
603
  }
583
604
  async function ensureTable() {
@@ -655,7 +676,7 @@ async function _doEnsureTable() {
655
676
  "updated_at",
656
677
  "expires_at",
657
678
  ]);
658
- // `cleanupExpiredAgentScratchResources` filters on exactly these two columns
679
+ // `scheduleExpiredAgentScratchCleanup` filters on exactly these two columns
659
680
  // and, without this, full-scans `resources` — a table shared by every
660
681
  // template and read from agent discovery, docs, chat scratch and remote-agent
661
682
  // manifests. Its 60s throttle is a module-scope timestamp that starts at 0 in
@@ -669,7 +690,20 @@ async function _doEnsureTable() {
669
690
  // coercion-ok: absence of an index degrades latency, never correctness
670
691
  console.warn("[resources] could not ensure resources_visibility_expires_idx; scratch cleanup will full-scan:", err?.message ?? err);
671
692
  });
672
- // Seed default shared resources if they don't exist (INSERT OR IGNORE to avoid race conditions)
693
+ // Seed default shared resources if they don't exist (INSERT OR IGNORE to avoid
694
+ // race conditions).
695
+ //
696
+ // Guarded by a durable marker: `_doEnsureTable` runs once per PROCESS, which on
697
+ // serverless is once per cold start, so this block was issuing ~10 writes and
698
+ // 2 migration scans per container to insert rows that had existed since day
699
+ // one (53,785 of them in one production sample). The marker makes it once per
700
+ // database, matching what the comments here already assumed.
701
+ //
702
+ // Consequence worth knowing: a default resource the user DELETES is no longer
703
+ // recreated on the next cold start. That silent resurrection was never
704
+ // intended — see `RESOURCE_SEED_VERSION` to force a re-seed.
705
+ if (await alreadySeeded(SHARED_SEED_KEY))
706
+ return;
673
707
  const now = Date.now();
674
708
  const seedSql = isPostgres()
675
709
  ? `INSERT INTO resources (id, path, owner, content, mime_type, size, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (path, owner) DO NOTHING`
@@ -797,8 +831,54 @@ async function _doEnsureTable() {
797
831
  catch {
798
832
  // Migration best-effort
799
833
  }
834
+ await markSeeded(SHARED_SEED_KEY);
800
835
  }
836
+ /**
837
+ * Bump when a default resource is ADDED, or when existing seed content must
838
+ * reach databases that already ran an earlier seed.
839
+ *
840
+ * The marker below records which version a database has been seeded at, so the
841
+ * seed runs once per database instead of once per process. Without a version, a
842
+ * newly added default would never reach an already-seeded deployment.
843
+ */
844
+ const RESOURCE_SEED_VERSION = 1;
845
+ /**
846
+ * Per-process memo ON TOP of the durable marker, so a warm process doesn't even
847
+ * pay the marker read. The durable marker is what makes this correct across cold
848
+ * starts; this only avoids a repeat lookup within one process.
849
+ */
801
850
  const _personalSeeded = new Set();
851
+ function personalSeedKey(owner) {
852
+ return `resources-seeded:personal:v${RESOURCE_SEED_VERSION}:${owner.toLowerCase()}`;
853
+ }
854
+ const SHARED_SEED_KEY = `resources-seeded:shared:v${RESOURCE_SEED_VERSION}`;
855
+ /**
856
+ * Has this database already been seeded at the current version?
857
+ *
858
+ * A failed marker read returns `false` — "seed again" — because the seeds are
859
+ * `ON CONFLICT DO NOTHING` and therefore idempotent. Guessing "already seeded"
860
+ * on an unreadable marker would skip seeding a fresh database and leave it
861
+ * without its default AGENTS.md / LEARNINGS.md, which is not recoverable by
862
+ * retrying a read.
863
+ */
864
+ async function alreadySeeded(key) {
865
+ try {
866
+ const row = await getSetting(key);
867
+ return row?.at !== undefined;
868
+ }
869
+ catch {
870
+ // coercion-ok: "not seeded" is the SAFE answer, and it is not indistinguishable from success — it re-runs an idempotent ON CONFLICT DO NOTHING seed, exactly the pre-marker behaviour. Guessing "already seeded" would leave a fresh database permanently without its default AGENTS.md / LEARNINGS.md, which no retry recovers.
871
+ return false;
872
+ }
873
+ }
874
+ async function markSeeded(key) {
875
+ try {
876
+ await putSetting(key, { at: Date.now() });
877
+ }
878
+ catch {
879
+ // coercion-ok: the marker is an optimization, not state anything reads for correctness. Failing to write it costs one repeat idempotent seed on the next cold start — the pre-marker behaviour — and the seed itself already succeeded, so there is no failure to report to the caller.
880
+ }
881
+ }
802
882
  /**
803
883
  * Seed personal AGENTS.md and LEARNINGS.md for a user if they don't exist.
804
884
  * Called when listing resources or from the agent chat plugin.
@@ -810,6 +890,14 @@ export async function ensurePersonalDefaults(owner) {
810
890
  return;
811
891
  }
812
892
  await ensureTable();
893
+ // The in-memory Set resets on every cold start, so on serverless this seed ran
894
+ // its 5 writes per container per owner, forever. The durable marker is what
895
+ // actually makes it once-per-owner.
896
+ const seedKey = personalSeedKey(owner);
897
+ if (await alreadySeeded(seedKey)) {
898
+ _personalSeeded.add(owner);
899
+ return;
900
+ }
813
901
  const client = getDbExec();
814
902
  const now = Date.now();
815
903
  const seedSql = isPostgres()
@@ -886,6 +974,7 @@ export async function ensurePersonalDefaults(owner) {
886
974
  // retries instead of permanently skipping seeding. Seeds use INSERT OR IGNORE
887
975
  // / ON CONFLICT DO NOTHING, so a concurrent re-run is harmless.
888
976
  _personalSeeded.add(owner);
977
+ await markSeeded(seedKey);
889
978
  }
890
979
  function rowToResource(row) {
891
980
  return {
@@ -932,7 +1021,7 @@ export async function resourceGet(id, options) {
932
1021
  return localWorkspaceResourceById(id);
933
1022
  }
934
1023
  const client = getDbExec();
935
- await cleanupExpiredAgentScratchResources(client);
1024
+ scheduleExpiredAgentScratchCleanup(client);
936
1025
  const { rows } = await client.execute({
937
1026
  sql: `SELECT * FROM resources WHERE id = ?`,
938
1027
  args: [id],
@@ -949,7 +1038,7 @@ export async function resourceGetByPath(owner, path, options) {
949
1038
  return local;
950
1039
  }
951
1040
  const client = getDbExec();
952
- await cleanupExpiredAgentScratchResources(client);
1041
+ scheduleExpiredAgentScratchCleanup(client);
953
1042
  const { rows } = await client.execute({
954
1043
  sql: `SELECT * FROM resources WHERE owner = ? AND path = ?`,
955
1044
  args: [owner, path],
@@ -1165,7 +1254,7 @@ export async function resourceDeleteByPath(owner, path) {
1165
1254
  export async function resourceList(owner, pathPrefix, options) {
1166
1255
  await ensureTable();
1167
1256
  const client = getDbExec();
1168
- await cleanupExpiredAgentScratchResources(client);
1257
+ scheduleExpiredAgentScratchCleanup(client);
1169
1258
  const visibilitySql = scratchFilterSql(options);
1170
1259
  if (pathPrefix) {
1171
1260
  const { rows } = await client.execute({
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
37
37
  ok?: undefined;
38
38
  status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
44
  ok?: undefined;
45
45
  error: string;
46
46
  removed?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
58
58
  error: string;
59
59
  note?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
@@ -117,6 +117,7 @@ import { isLocalhost, shouldBlockInProductCodeEditingSurface, } from "./agent-ch
117
117
  import { loadRunCodeToolEntries } from "./agent-chat/run-code-tools.js";
118
118
  import { createAgentEngineScriptEntries, createAgentLoopSettingsScriptEntries, createCallAgentScriptEntry, createChatScriptEntries, createDbScriptEntries, createDocsScriptEntries, createResourceScriptEntries, } from "./agent-chat/script-entries.js";
119
119
  import { parseSkillFrontmatter } from "./agent-chat/skill-frontmatter.js";
120
+ import { shouldDisableInProcessSweeps } from "./sweep-runtime.js";
120
121
  export { loadResourcesForPrompt };
121
122
  export { _agentChatPromptSectionsForTests };
122
123
  export { buildPublicAgentA2ASkills };
@@ -4735,6 +4736,17 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4735
4736
  }));
4736
4737
  const isBackgroundRuntime = isInBackgroundFunctionRuntime();
4737
4738
  const disableRecurringJobsRuntime = isBackgroundRuntime || shouldDisableRecurringJobsRuntime();
4739
+ // Opt-in kill switch for the backstop sweep timers below. On serverless
4740
+ // these are billed per warm container, so their query rate scales with
4741
+ // instance count rather than with anything happening. See
4742
+ // `shouldDisableInProcessSweeps`.
4743
+ const sweepsDisabled = shouldDisableInProcessSweeps();
4744
+ if (sweepsDisabled) {
4745
+ console.log("[agent-native] In-process backstop sweeps disabled " +
4746
+ "(AGENT_NATIVE_DISABLE_INPROCESS_SWEEPS). A durable scheduler must " +
4747
+ "drive automation redispatch, agent-teams reconciliation, and " +
4748
+ "sandbox-execution recovery, or queued work will not be retried.");
4749
+ }
4738
4750
  // ─── Recurring Jobs Scheduler ──────────────────────────────────────
4739
4751
  // Poll every 60 seconds for due recurring jobs and execute them.
4740
4752
  // Uses setInterval so it works in all deployment environments without
@@ -4832,7 +4844,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4832
4844
  // A non-Netlify self-dispatch only waits for the request to leave the
4833
4845
  // current invocation. Keep the durable history row as a retryable queue
4834
4846
  // so a frozen serverless handoff is recovered on the next sweep.
4835
- if (!isBackgroundRuntime) {
4847
+ if (!isBackgroundRuntime && !sweepsDisabled) {
4836
4848
  (() => {
4837
4849
  let inFlight = false;
4838
4850
  const sweep = async () => {
@@ -4864,7 +4876,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4864
4876
  // per instance; cheap (one indexed query when no active tasks are found).
4865
4877
  // Throttled by the same per-owner interval guard inside reconcileAgentTeamRunsForOwner.
4866
4878
  (() => {
4867
- if (isBackgroundRuntime)
4879
+ if (isBackgroundRuntime || sweepsDisabled)
4868
4880
  return;
4869
4881
  // Track when this instance last ran the sweep so only one sweep fires
4870
4882
  // per 2-min window even if multiple timers fire in overlapping invocations.
@@ -5013,7 +5025,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
5013
5025
  // comment above for why this exists and the timing budget in
5014
5026
  // run-store.ts's `UNCLAIMED_BACKGROUND_RUN_FAST_SWEEP_MS` doc comment.
5015
5027
  (() => {
5016
- if (isBackgroundRuntime)
5028
+ if (isBackgroundRuntime || sweepsDisabled)
5017
5029
  return;
5018
5030
  setTimeout(() => {
5019
5031
  (async () => {
@@ -5056,7 +5068,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
5056
5068
  // still fails loud, and it survives the fast sweep having missed a row
5057
5069
  // entirely (e.g. a restart landed between fast-sweep ticks).
5058
5070
  (() => {
5059
- if (isBackgroundRuntime)
5071
+ if (isBackgroundRuntime || sweepsDisabled)
5060
5072
  return;
5061
5073
  let lastSweep = 0;
5062
5074
  const SWEEP_INTERVAL_MS = 2 * 60 * 1000;
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
+ error?: undefined;
30
31
  ok: boolean;
31
32
  key: string;
32
33
  baseUrlKey?: string;
33
34
  scope: AgentEngineApiKeyScope;
34
- error?: undefined;
35
35
  }>>;
36
36
  export {};
@@ -226,6 +226,9 @@ export declare function removeSession(token: string): Promise<void>;
226
226
  /**
227
227
  * Look up the email associated with a legacy session token.
228
228
  * Returns null if the session doesn't exist, is expired, or has no email.
229
+ *
230
+ * Resolutions are cached across requests — see `session-email-cache.ts` for the
231
+ * TTL and the only-cache-successes rule.
229
232
  */
230
233
  export declare function getSessionEmail(token: string): Promise<string | null>;
231
234
  export interface DesktopExchangeErrorPayload {
@@ -71,6 +71,7 @@ import { isIdentitySsoEnabled } from "./identity-sso-store.js";
71
71
  import { safeOAuthReturnUrl } from "./oauth-return-url.js";
72
72
  import { getOnboardingHtml, getResetPasswordHtml, } from "./onboarding-html.js";
73
73
  import { captureAuthError } from "./sentry.js";
74
+ import { forgetCachedSessionEmail, getCachedSessionEmail, invalidateSessionEmailCache, setCachedSessionEmail, } from "./session-email-cache.js";
74
75
  import { isWorkspaceOAuthCallbackRelayEnabled } from "./workspace-oauth.js";
75
76
  /**
76
77
  * Get the configured session max age. Desktop SSO broker writes from
@@ -741,6 +742,9 @@ export async function addSession(token, email) {
741
742
  : `INSERT OR REPLACE INTO sessions (token, email, created_at) VALUES (?, ?, ?)`,
742
743
  args: [token, email ?? null, Date.now()],
743
744
  }));
745
+ // The upsert can REBIND an existing token to a different email, so a cached
746
+ // resolution for it is now wrong.
747
+ invalidateSessionEmailCache();
744
748
  }
745
749
  export async function hasLegacySessionForEmail(email) {
746
750
  await ensureSessionTable();
@@ -759,12 +763,20 @@ export async function removeSession(token) {
759
763
  sql: `DELETE FROM sessions WHERE token = ?`,
760
764
  args: [token],
761
765
  }));
766
+ // Sign-out must take effect immediately, not after the cache TTL.
767
+ invalidateSessionEmailCache();
762
768
  }
763
769
  /**
764
770
  * Look up the email associated with a legacy session token.
765
771
  * Returns null if the session doesn't exist, is expired, or has no email.
772
+ *
773
+ * Resolutions are cached across requests — see `session-email-cache.ts` for the
774
+ * TTL and the only-cache-successes rule.
766
775
  */
767
776
  export async function getSessionEmail(token) {
777
+ const cached = getCachedSessionEmail(token);
778
+ if (cached !== undefined)
779
+ return cached;
768
780
  await ensureSessionTable();
769
781
  const client = getDbExec();
770
782
  const { rows } = await retryIfSessionsMissing(() => client.execute({
@@ -779,9 +791,13 @@ export async function getSessionEmail(token) {
779
791
  sql: `DELETE FROM sessions WHERE token = ?`,
780
792
  args: [token],
781
793
  });
794
+ forgetCachedSessionEmail(token);
782
795
  return null;
783
796
  }
784
- return rows[0].email ?? null;
797
+ const email = rows[0].email ?? null;
798
+ if (email)
799
+ setCachedSessionEmail(token, email);
800
+ return email;
785
801
  }
786
802
  // ---------------------------------------------------------------------------
787
803
  // getSession — the auth contract
@@ -937,6 +953,7 @@ async function consumeDesktopExchangeFromDB(flowId) {
937
953
  sql: `DELETE FROM sessions WHERE token = ? AND created_at > ? RETURNING email`,
938
954
  args: [`dex:${flowId}`, Date.now() - DESKTOP_EXCHANGE_TTL_MS],
939
955
  });
956
+ forgetCachedSessionEmail(`dex:${flowId}`);
940
957
  if (rows.length === 0)
941
958
  return null;
942
959
  const packed = (rows[0].email ?? rows[0][0]);
@@ -2706,6 +2723,9 @@ async function mountBetterAuthRoutes(app, options) {
2706
2723
  args: [userEmail],
2707
2724
  });
2708
2725
  }
2726
+ // Deleted by email, so the removed tokens are unknown here — the
2727
+ // whole cache goes.
2728
+ invalidateSessionEmailCache();
2709
2729
  }
2710
2730
  }
2711
2731
  catch {
@@ -2939,6 +2959,7 @@ async function mountBetterAuthRoutes(app, options) {
2939
2959
  catch {
2940
2960
  // Best-effort.
2941
2961
  }
2962
+ invalidateSessionEmailCache();
2942
2963
  // 3. Drop the current request's cookie and best-effort sign out
2943
2964
  // of Better Auth (so the response sets the proper expiry header).
2944
2965
  clearFrameworkSessionCookies(event);
@@ -211,8 +211,12 @@ export function createCollabPlugin(options = {}) {
211
211
  (action === "json" && method === "POST") ||
212
212
  (action === "patch" && method === "POST");
213
213
  if (isWrite) {
214
- // assertAccess throws ForbiddenError (→ 403) if no editor access
215
- const access = await assertAccess(resourceType, resourceId, "editor");
214
+ // assertAccess throws ForbiddenError (→ 403) if no editor access.
215
+ // Projected: only ownerEmail/orgId are read below, and a collab
216
+ // resource's body is the largest column it has — loading it here
217
+ // means every keystroke-driven update read the whole document
218
+ // twice, once for the ACL and once for the edit itself.
219
+ const access = await assertAccess(resourceType, resourceId, "editor", undefined, { skipResourceBody: true });
216
220
  const resource = access.resource;
217
221
  const awarenessScope = {
218
222
  resourceType,
@@ -229,8 +233,10 @@ export function createCollabPlugin(options = {}) {
229
233
  }
230
234
  }
231
235
  else {
232
- // resolveAccess returns null when no access; return 404 to avoid leaking existence
233
- const access = await resolveAccess(resourceType, resourceId);
236
+ // resolveAccess returns null when no access; return 404 to avoid leaking existence.
237
+ // Projected: only ownerEmail/orgId are read below, and a collab
238
+ // resource's body is the largest column it has.
239
+ const access = await resolveAccess(resourceType, resourceId, undefined, { skipResourceBody: true });
234
240
  if (!access) {
235
241
  setResponseStatus(event, 404);
236
242
  return { error: "Not found" };
@@ -57,6 +57,7 @@ import { createPollHandler } from "./poll.js";
57
57
  import { createRealtimeTokenHandler } from "./realtime-token.js";
58
58
  import { getRequestContext, hasRequestContext, runWithRequestContext, } from "./request-context.js";
59
59
  import { findUnsupportedScopedKeyNames, saveKeyValuesToScopedSecrets, ScopedKeyStorageError, } from "./scoped-key-storage.js";
60
+ import { shouldDisableInProcessSweeps } from "./sweep-runtime.js";
60
61
  import { createTranscribeVoiceHandler } from "./transcribe-voice.js";
61
62
  import { createVoiceProvidersStatusHandler } from "./voice-providers-status.js";
62
63
  import { createWorkspaceProviderOAuthHandler } from "./workspace-provider-oauth.js";
@@ -1260,6 +1261,8 @@ export function createCoreRoutesPlugin(options = {}) {
1260
1261
  // poll-time drain in run-code covers deployments where warm-instance
1261
1262
  // timers rarely fire.
1262
1263
  (() => {
1264
+ if (shouldDisableInProcessSweeps())
1265
+ return;
1263
1266
  let lastSweep = 0;
1264
1267
  const SWEEP_INTERVAL_MS = 2 * 60 * 1000;
1265
1268
  setTimeout(() => {
@@ -0,0 +1,14 @@
1
+ export declare function getCachedSessionEmail(token: string): string | undefined;
2
+ export declare function setCachedSessionEmail(token: string, email: string): void;
3
+ export declare function forgetCachedSessionEmail(token: string): void;
4
+ /**
5
+ * Drop cached session resolutions. Call after ANY write to `sessions`.
6
+ *
7
+ * Clears every token rather than one: the bulk sign-out paths delete by email
8
+ * (`DELETE FROM sessions WHERE email = ?`) and do not know which tokens they
9
+ * removed, so a per-token eviction there would leave a signed-out session
10
+ * resolving for the rest of the TTL.
11
+ */
12
+ export declare function invalidateSessionEmailCache(): void;
13
+ /** Test seam — the cache is module state, so suites must be able to clear it. */
14
+ export declare function __resetSessionEmailCacheForTests(): void;
@@ -0,0 +1,48 @@
1
+ import { createTtlCache } from "../shared/ttl-cache.js";
2
+ /**
3
+ * Cross-request cache for resolved legacy session tokens.
4
+ *
5
+ * A session token is immutable for its (30-day) lifetime, yet every
6
+ * authenticated request re-read it: production showed 494,848 of these for 3
7
+ * users, 1:1 with the request count. The TTL is far shorter than the session
8
+ * lifetime, so expiry is enforced at most `SESSION_EMAIL_TTL_MS` late — a 30-day
9
+ * boundary observed up to 15s late changes nothing.
10
+ *
11
+ * ONLY successful resolutions belong in here. A `null` — no such token, expired,
12
+ * revoked — must stay uncached so a sign-out or a token that has just been
13
+ * written is never answered from a stale negative, and so an auth failure can
14
+ * never be made sticky by a cache.
15
+ *
16
+ * Lives in its own module rather than in `auth.ts` because `auth.ts` imports
17
+ * `org/auth-policy.ts`, which also revokes sessions and so needs to invalidate
18
+ * this. Importing back into `auth.ts` from there would be a cycle.
19
+ */
20
+ const SESSION_EMAIL_TTL_MS = 15_000;
21
+ const sessionEmailCache = createTtlCache({
22
+ ttlMs: SESSION_EMAIL_TTL_MS,
23
+ maxEntries: 4_096,
24
+ });
25
+ export function getCachedSessionEmail(token) {
26
+ return sessionEmailCache.get(token);
27
+ }
28
+ export function setCachedSessionEmail(token, email) {
29
+ sessionEmailCache.set(token, email);
30
+ }
31
+ export function forgetCachedSessionEmail(token) {
32
+ sessionEmailCache.delete(token);
33
+ }
34
+ /**
35
+ * Drop cached session resolutions. Call after ANY write to `sessions`.
36
+ *
37
+ * Clears every token rather than one: the bulk sign-out paths delete by email
38
+ * (`DELETE FROM sessions WHERE email = ?`) and do not know which tokens they
39
+ * removed, so a per-token eviction there would leave a signed-out session
40
+ * resolving for the rest of the TTL.
41
+ */
42
+ export function invalidateSessionEmailCache() {
43
+ sessionEmailCache.clear();
44
+ }
45
+ /** Test seam — the cache is module state, so suites must be able to clear it. */
46
+ export function __resetSessionEmailCacheForTests() {
47
+ sessionEmailCache.clear();
48
+ }
@@ -0,0 +1,22 @@
1
+ type SweepRuntimeEnvKey = "AGENT_NATIVE_DISABLE_INPROCESS_SWEEPS";
2
+ type SweepRuntimeEnv = Partial<Record<SweepRuntimeEnvKey, string | undefined>>;
3
+ /**
4
+ * Kill switch for the `setInterval` backstop sweeps.
5
+ *
6
+ * These timers are correct on a long-lived Node server: one process, one timer
7
+ * set, one query per tick. On a serverless host they are billed per WARM
8
+ * CONTAINER, not per deployment — every warm instance independently runs the
9
+ * whole timer set, so the sweep query rate scales with instance count, which no
10
+ * in-process backoff or `lastSweep` guard can observe. A production sample
11
+ * showed one 2-minute sweep issuing 237k queries, implying ~11 containers each
12
+ * ticking forever against a database that then never gets to autosuspend.
13
+ *
14
+ * Default OFF (sweeps run), because on every host where the timers are the only
15
+ * thing driving recovery, disabling them silently strands queued work. Turn this
16
+ * on ONLY where a durable platform scheduler already drives the same recovery —
17
+ * the shape `isNetlifyRecurringJobsRuntime` + the emitted scheduled sweep
18
+ * already implement for recurring jobs, and which these three sweeps should
19
+ * eventually adopt instead of needing this switch at all.
20
+ */
21
+ export declare function shouldDisableInProcessSweeps(env?: SweepRuntimeEnv): boolean;
22
+ export {};
@@ -0,0 +1,28 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Gating for the in-process backstop sweep timers (automation redispatch,
3
+ // agent-teams orphan reconciliation, durable sandbox executions).
4
+ // ---------------------------------------------------------------------------
5
+ function isTruthyEnv(value) {
6
+ return /^(1|true|yes|on)$/i.test(value?.trim() ?? "");
7
+ }
8
+ /**
9
+ * Kill switch for the `setInterval` backstop sweeps.
10
+ *
11
+ * These timers are correct on a long-lived Node server: one process, one timer
12
+ * set, one query per tick. On a serverless host they are billed per WARM
13
+ * CONTAINER, not per deployment — every warm instance independently runs the
14
+ * whole timer set, so the sweep query rate scales with instance count, which no
15
+ * in-process backoff or `lastSweep` guard can observe. A production sample
16
+ * showed one 2-minute sweep issuing 237k queries, implying ~11 containers each
17
+ * ticking forever against a database that then never gets to autosuspend.
18
+ *
19
+ * Default OFF (sweeps run), because on every host where the timers are the only
20
+ * thing driving recovery, disabling them silently strands queued work. Turn this
21
+ * on ONLY where a durable platform scheduler already drives the same recovery —
22
+ * the shape `isNetlifyRecurringJobsRuntime` + the emitted scheduled sweep
23
+ * already implement for recurring jobs, and which these three sweeps should
24
+ * eventually adopt instead of needing this switch at all.
25
+ */
26
+ export function shouldDisableInProcessSweeps(env = process.env) {
27
+ return isTruthyEnv(env.AGENT_NATIVE_DISABLE_INPROCESS_SWEEPS);
28
+ }
@@ -25,6 +25,24 @@ function requestSettingsCache() {
25
25
  }
26
26
  return cache;
27
27
  }
28
+ /**
29
+ * Per-request memo of the WHOLE settings table for `getAllSettings`.
30
+ *
31
+ * The single-key path above was already request-cached; the full-table read was
32
+ * not, and production showed 98,479 of them — an entire `SELECT key, value FROM
33
+ * settings` per call, several times per request via the MCP client routes and
34
+ * org settings.
35
+ *
36
+ * Holds raw JSON strings, like the single-key cache, so callers can't mutate a
37
+ * shared parsed object. Dropped by every write path, because a request that
38
+ * writes a setting and then re-reads all of them must see its own write.
39
+ */
40
+ const _requestAllSettingsCache = new WeakMap();
41
+ function invalidateRequestAllSettings() {
42
+ const ctx = getRequestContext();
43
+ if (ctx && typeof ctx === "object")
44
+ _requestAllSettingsCache.delete(ctx);
45
+ }
28
46
  // Created lazily so this module can be evaluated in the browser dev graph
29
47
  // without a top-level `new EventEmitter()` tripping Vite's externalized
30
48
  // `node:events` stub. The emitter drives server-side SSE fan-out only.
@@ -152,6 +170,7 @@ export async function mutateSetting(key, updater, options) {
152
170
  if (result.rowsAffected === 0)
153
171
  continue;
154
172
  requestSettingsCache()?.set(key, nextRaw);
173
+ invalidateRequestAllSettings();
155
174
  settingsEmitter().emit("settings", {
156
175
  source: "settings",
157
176
  type: "change",
@@ -173,6 +192,7 @@ export async function putSetting(key, value, options) {
173
192
  args: [key, JSON.stringify(value), Date.now()],
174
193
  });
175
194
  requestSettingsCache()?.set(key, JSON.stringify(value));
195
+ invalidateRequestAllSettings();
176
196
  settingsEmitter().emit("settings", {
177
197
  source: "settings",
178
198
  type: "change",
@@ -189,6 +209,7 @@ export async function deleteSetting(key, options) {
189
209
  args: [key],
190
210
  });
191
211
  requestSettingsCache()?.set(key, null);
212
+ invalidateRequestAllSettings();
192
213
  if (result.rowsAffected > 0) {
193
214
  settingsEmitter().emit("settings", {
194
215
  source: "settings",
@@ -215,6 +236,7 @@ export async function deleteSettingsByPrefix(prefix, options) {
215
236
  args: [`${escaped}%`],
216
237
  });
217
238
  requestSettingsCache()?.clear();
239
+ invalidateRequestAllSettings();
218
240
  if (result.rowsAffected > 0) {
219
241
  settingsEmitter().emit("settings", {
220
242
  source: "settings",
@@ -226,13 +248,47 @@ export async function deleteSettingsByPrefix(prefix, options) {
226
248
  return result.rowsAffected;
227
249
  }
228
250
  export async function getAllSettings() {
229
- await ensureTable();
230
- const client = getDbExec();
231
- const table = settingsTable();
232
- const { rows } = await client.execute(`SELECT key, value FROM ${table}`);
251
+ const raw = await loadAllSettingsRaw();
233
252
  const result = {};
234
- for (const row of rows) {
235
- result[row.key] = JSON.parse(row.value);
236
- }
253
+ for (const [key, value] of raw)
254
+ result[key] = JSON.parse(value);
237
255
  return result;
238
256
  }
257
+ async function loadAllSettingsRaw() {
258
+ const ctx = getRequestContext();
259
+ const cached = ctx && typeof ctx === "object"
260
+ ? _requestAllSettingsCache.get(ctx)
261
+ : undefined;
262
+ if (cached)
263
+ return cached;
264
+ const load = (async () => {
265
+ await ensureTable();
266
+ const client = getDbExec();
267
+ const table = settingsTable();
268
+ const { rows } = await client.execute(`SELECT key, value FROM ${table}`);
269
+ const raw = new Map();
270
+ for (const row of rows)
271
+ raw.set(row.key, row.value);
272
+ // Seed the single-key cache so a `getSetting` after `getAllSettings` in the
273
+ // same request is free rather than another round trip. Only fills keys it
274
+ // does not already hold: a value written earlier in this request is already
275
+ // written through there and must win over this snapshot.
276
+ const perKey = requestSettingsCache();
277
+ if (perKey) {
278
+ for (const [key, value] of raw) {
279
+ if (!perKey.has(key))
280
+ perKey.set(key, value);
281
+ }
282
+ }
283
+ return raw;
284
+ })();
285
+ if (ctx && typeof ctx === "object") {
286
+ // Evicted on failure so one transient error is not memoized as the answer
287
+ // for the rest of the request.
288
+ _requestAllSettingsCache.set(ctx, load.catch((err) => {
289
+ _requestAllSettingsCache.delete(ctx);
290
+ throw err;
291
+ }));
292
+ }
293
+ return load;
294
+ }