@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.
- package/dist/agent/run-manager.d.ts +44 -1
- package/dist/agent/run-manager.js +70 -8
- package/dist/chat-threads/store.js +6 -4
- package/dist/client/use-db-sync.js +9 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/ddl-guard.d.ts +8 -0
- package/dist/db/ddl-guard.js +148 -0
- package/dist/deploy/build.d.ts +36 -0
- package/dist/deploy/build.js +78 -5
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/org/accept-pending.js +2 -2
- package/dist/org/auth-policy.js +4 -0
- package/dist/org/auto-join-domain.d.ts +9 -0
- package/dist/org/auto-join-domain.js +53 -4
- package/dist/org/context.js +12 -3
- package/dist/org/handlers.js +17 -5
- package/dist/org/request-org-cache.d.ts +24 -2
- package/dist/org/request-org-cache.js +57 -2
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/resources/store.d.ts +1 -1
- package/dist/resources/store.js +96 -7
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-chat-plugin.js +16 -4
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.d.ts +3 -0
- package/dist/server/auth.js +22 -1
- package/dist/server/collab-plugin.js +10 -4
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/session-email-cache.d.ts +14 -0
- package/dist/server/session-email-cache.js +48 -0
- package/dist/server/sweep-runtime.d.ts +22 -0
- package/dist/server/sweep-runtime.js +28 -0
- package/dist/settings/store.js +63 -7
- package/dist/shared/ttl-cache.d.ts +59 -0
- package/dist/shared/ttl-cache.js +43 -0
- package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
- package/dist/sharing/actions/list-resource-shares.js +4 -1
- package/dist/triggers/condition-evaluator.js +11 -27
- package/docs/content/deployment.mdx +48 -0
- package/docs/content/environment-variables.mdx +5 -0
- package/package.json +3 -3
package/dist/resources/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1257
|
+
scheduleExpiredAgentScratchCleanup(client);
|
|
1169
1258
|
const visibilitySql = scratchFilterSql(options);
|
|
1170
1259
|
if (pathPrefix) {
|
|
1171
1260
|
const { rows } = await client.execute({
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -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 {
|
package/dist/server/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/settings/store.js
CHANGED
|
@@ -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
|
|
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
|
|
235
|
-
result[
|
|
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
|
+
}
|