@agent-native/core 0.144.3 → 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/corpus/README.md +1 -1
- package/corpus/templates/clips/changelog/2026-08-06-desktop-full-screen-recordings-now-normalize-microphone-leve.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +185 -69
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +47 -4
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
- package/dist/agent/production-agent.d.ts +6 -0
- 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/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/framework-tools.d.ts +126 -0
- package/dist/framework-tools.js +114 -0
- package/dist/notifications/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/provider-api/actions/custom-provider-registration.d.ts +6 -6
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/store.d.ts +1 -1
- package/dist/resources/store.js +96 -7
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-discovery.d.ts +27 -0
- package/dist/server/action-discovery.js +89 -0
- package/dist/server/agent-chat/action-filters-a2a.d.ts +11 -0
- package/dist/server/agent-chat/action-filters-a2a.js +17 -1
- package/dist/server/agent-chat/framework-prompts.d.ts +2 -0
- package/dist/server/agent-chat/plugin-options.d.ts +30 -0
- package/dist/server/agent-chat/prompt-resources.d.ts +4 -1
- package/dist/server/agent-chat/prompt-resources.js +8 -2
- package/dist/server/agent-chat-plugin.js +138 -75
- 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/prompts/framework-core-compact.d.ts +5 -0
- package/dist/server/prompts/framework-core-compact.js +28 -12
- package/dist/server/prompts/framework-core.d.ts +5 -0
- package/dist/server/prompts/framework-core.js +33 -16
- package/dist/server/prompts/index.d.ts +1 -1
- package/dist/server/prompts/index.js +1 -1
- package/dist/server/prompts/shared-rules.d.ts +12 -4
- package/dist/server/prompts/shared-rules.js +14 -4
- 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/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
- package/dist/triggers/condition-evaluator.js +11 -27
- package/docs/content/deployment.mdx +95 -13
- package/docs/content/environment-variables.mdx +5 -0
- package/docs/content/key-concepts.mdx +13 -6
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -3
|
@@ -197,16 +197,69 @@ export const SQL_SUBSCRIPTION_IDLE_POLL_MS = 500;
|
|
|
197
197
|
export const SQL_SUBSCRIPTION_ACTIVE_GRACE_MS = 2_000;
|
|
198
198
|
/** Keep terminal/status probes at the historical cadence to bound DB work. */
|
|
199
199
|
export const SQL_SUBSCRIPTION_STATUS_POLL_MS = 500;
|
|
200
|
+
/**
|
|
201
|
+
* Consecutive empty polls before the IDLE cadence starts decaying toward
|
|
202
|
+
* `SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS`.
|
|
203
|
+
*
|
|
204
|
+
* The active grace already covers a streaming producer, so decay only ever
|
|
205
|
+
* applies to a subscriber watching a run that is producing nothing: a long tool
|
|
206
|
+
* call, a slow first token, or a wedged producer. Those cost one poll per
|
|
207
|
+
* 500ms each, forever, per subscriber — the single largest source of idle
|
|
208
|
+
* `agent_run_events` reads.
|
|
209
|
+
*/
|
|
210
|
+
export const SQL_SUBSCRIPTION_IDLE_DECAY_AFTER_POLLS = 4;
|
|
211
|
+
/**
|
|
212
|
+
* Ceiling for the decayed idle cadence. Bounds the WORST-CASE added latency to
|
|
213
|
+
* the next token a quiet run eventually produces; a row already waiting when the
|
|
214
|
+
* timer fires is delivered immediately, so this is not added to a streaming run.
|
|
215
|
+
*/
|
|
216
|
+
export const SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS = 2_000;
|
|
217
|
+
/**
|
|
218
|
+
* Cadence for the opportunistic `reapIfStale` probe inside the SSE poll loop,
|
|
219
|
+
* kept SEPARATE from (and much slower than) the status probe next to it.
|
|
220
|
+
*
|
|
221
|
+
* A reap can only ever act on a run whose liveness basis is older than the
|
|
222
|
+
* tightest staleness window any sweep enforces — `RUN_STALE_MS`, 15s — so
|
|
223
|
+
* running it on the 500ms status cadence issued ~30 rounds of
|
|
224
|
+
* `reconcileTerminalRunFromEvents` + reap-eligibility queries (2-4 round trips
|
|
225
|
+
* each) before the first one could possibly match a row. This cadence still
|
|
226
|
+
* detects a stale producer well inside the client's own idle timeout while
|
|
227
|
+
* cutting that probe traffic ~10x. `getRunById` deliberately stays on the fast
|
|
228
|
+
* status cadence: it is one indexed read and it is what closes the stream on a
|
|
229
|
+
* NORMAL finish, which must stay prompt.
|
|
230
|
+
*/
|
|
231
|
+
export const SQL_SUBSCRIPTION_REAP_POLL_MS = 5_000;
|
|
200
232
|
/** Initial retry delay after a transient cross-isolate SQL polling failure. */
|
|
201
233
|
export const SQL_SUBSCRIPTION_RETRY_BASE_MS = 250;
|
|
202
234
|
/** Bound consecutive SQL polling failures so a dead subscription fails loud. */
|
|
203
235
|
export const SQL_SUBSCRIPTION_MAX_CONSECUTIVE_FAILURES = 4;
|
|
204
236
|
/** Cap retry delay even if the failure bound changes independently. */
|
|
205
237
|
export const SQL_SUBSCRIPTION_RETRY_MAX_MS = 2_000;
|
|
206
|
-
export function resolveSqlSubscriptionPollMs(now, activePollUntil) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
238
|
+
export function resolveSqlSubscriptionPollMs(now, activePollUntil, consecutiveEmptyPolls = 0) {
|
|
239
|
+
if (now < activePollUntil)
|
|
240
|
+
return SQL_SUBSCRIPTION_ACTIVE_POLL_MS;
|
|
241
|
+
// Clamped before exponentiation: an unbounded `2 ** steps` reaches Infinity
|
|
242
|
+
// and would make the cap the only thing keeping this finite.
|
|
243
|
+
const steps = Math.min(16, Math.max(0, Math.floor(consecutiveEmptyPolls) -
|
|
244
|
+
SQL_SUBSCRIPTION_IDLE_DECAY_AFTER_POLLS));
|
|
245
|
+
return Math.min(SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS, SQL_SUBSCRIPTION_IDLE_POLL_MS * 2 ** steps);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Advance the empty-poll counter that drives `resolveSqlSubscriptionPollMs`'s
|
|
249
|
+
* decay.
|
|
250
|
+
*
|
|
251
|
+
* Only IDLE polls count. Counting the ~16 fast polls inside the active grace
|
|
252
|
+
* window would push the ladder to its cap the instant the grace expired, so a
|
|
253
|
+
* producer that pauses a few seconds between tokens would resume at the 2s cap
|
|
254
|
+
* instead of 500ms — a visible mid-stream stutter rather than the intended
|
|
255
|
+
* "this run has gone quiet" backoff.
|
|
256
|
+
*/
|
|
257
|
+
export function nextSqlSubscriptionEmptyPolls(current, hadEvents, now, activePollUntil) {
|
|
258
|
+
if (hadEvents)
|
|
259
|
+
return 0;
|
|
260
|
+
if (now < activePollUntil)
|
|
261
|
+
return current;
|
|
262
|
+
return current + 1;
|
|
210
263
|
}
|
|
211
264
|
export function resolveSqlSubscriptionRetryMs(consecutiveFailures) {
|
|
212
265
|
const retryIndex = Math.max(0, Math.floor(consecutiveFailures) - 1);
|
|
@@ -1323,7 +1376,9 @@ function subscribeFromSQL(runId, fromSeq) {
|
|
|
1323
1376
|
let lastSeq = fromSeq;
|
|
1324
1377
|
let activePollUntil = 0;
|
|
1325
1378
|
let lastStatusCheckAt = 0;
|
|
1379
|
+
let lastReapCheckAt = 0;
|
|
1326
1380
|
let consecutivePollFailures = 0;
|
|
1381
|
+
let consecutiveEmptyPolls = 0;
|
|
1327
1382
|
const ping = () => {
|
|
1328
1383
|
try {
|
|
1329
1384
|
controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
|
|
@@ -1382,6 +1437,7 @@ function subscribeFromSQL(runId, fromSeq) {
|
|
|
1382
1437
|
try {
|
|
1383
1438
|
// Read new events from SQL
|
|
1384
1439
|
const events = await getRunEventsSince(runId, lastSeq);
|
|
1440
|
+
consecutiveEmptyPolls = nextSqlSubscriptionEmptyPolls(consecutiveEmptyPolls, events.length > 0, Date.now(), activePollUntil);
|
|
1385
1441
|
if (events.length > 0) {
|
|
1386
1442
|
activePollUntil = Date.now() + SQL_SUBSCRIPTION_ACTIVE_GRACE_MS;
|
|
1387
1443
|
}
|
|
@@ -1419,7 +1475,7 @@ function subscribeFromSQL(runId, fromSeq) {
|
|
|
1419
1475
|
if (now - lastStatusCheckAt < SQL_SUBSCRIPTION_STATUS_POLL_MS) {
|
|
1420
1476
|
if (!cancelled) {
|
|
1421
1477
|
consecutivePollFailures = 0;
|
|
1422
|
-
const pollMs = resolveSqlSubscriptionPollMs(now, activePollUntil);
|
|
1478
|
+
const pollMs = resolveSqlSubscriptionPollMs(now, activePollUntil, consecutiveEmptyPolls);
|
|
1423
1479
|
pollTimer = setTimeout(poll, pollMs);
|
|
1424
1480
|
}
|
|
1425
1481
|
return;
|
|
@@ -1427,8 +1483,14 @@ function subscribeFromSQL(runId, fromSeq) {
|
|
|
1427
1483
|
lastStatusCheckAt = now;
|
|
1428
1484
|
// Opportunistically reap a stale producer before trusting SQL's
|
|
1429
1485
|
// "running" status — otherwise a crashed server leaves us polling
|
|
1430
|
-
// forever.
|
|
1431
|
-
|
|
1486
|
+
// forever. Throttled independently of the status probe below: a reap
|
|
1487
|
+
// cannot match a row younger than RUN_STALE_MS, so running it at the
|
|
1488
|
+
// status cadence was ~30 rounds of wasted round trips per run before
|
|
1489
|
+
// the first one could do anything. See SQL_SUBSCRIPTION_REAP_POLL_MS.
|
|
1490
|
+
if (now - lastReapCheckAt >= SQL_SUBSCRIPTION_REAP_POLL_MS) {
|
|
1491
|
+
lastReapCheckAt = now;
|
|
1492
|
+
await reapIfStale(runId).catch(() => { });
|
|
1493
|
+
}
|
|
1432
1494
|
const run = await getRunById(runId);
|
|
1433
1495
|
if (!run || run.status !== "running") {
|
|
1434
1496
|
// Run ended — do one final event read, then close
|
|
@@ -1543,7 +1605,7 @@ function subscribeFromSQL(runId, fromSeq) {
|
|
|
1543
1605
|
// Schedule next poll
|
|
1544
1606
|
if (!cancelled) {
|
|
1545
1607
|
consecutivePollFailures = 0;
|
|
1546
|
-
const pollMs = resolveSqlSubscriptionPollMs(Date.now(), activePollUntil);
|
|
1608
|
+
const pollMs = resolveSqlSubscriptionPollMs(Date.now(), activePollUntil, consecutiveEmptyPolls);
|
|
1547
1609
|
pollTimer = setTimeout(poll, pollMs);
|
|
1548
1610
|
}
|
|
1549
1611
|
}
|
|
@@ -429,10 +429,12 @@ export async function ensureChatThreadTables() {
|
|
|
429
429
|
export async function resolveThreadAccess(userEmail, threadId, minRole = "viewer", ctx = {}) {
|
|
430
430
|
if (!userEmail || !threadId)
|
|
431
431
|
return null;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
432
|
+
// `skipResourceBody` matters more here than anywhere else: without it the
|
|
433
|
+
// access load is an unprojected `select()` that pulls `thread_data` — the
|
|
434
|
+
// whole conversation JSON — and then this function discards the row and reads
|
|
435
|
+
// it again through `getThread`. Two full-blob reads of the same row per call,
|
|
436
|
+
// on the agent-chat hot path.
|
|
437
|
+
const access = await resolveAccess("chat_thread", threadId, { userEmail, orgId: ctx.orgId }, { skipResourceBody: true });
|
|
436
438
|
if (!access || !roleSatisfies(access.role, minRole))
|
|
437
439
|
return null;
|
|
438
440
|
return await getThread(threadId);
|
|
@@ -1216,7 +1216,15 @@ export function useDbSync(options = {}) {
|
|
|
1216
1216
|
// the independent framework prefixes, while pure action batches
|
|
1217
1217
|
// retain their narrow storm-resistant invalidation.
|
|
1218
1218
|
if (!hasActionEvent) {
|
|
1219
|
-
|
|
1219
|
+
// Honour the app's predicate here too. Without it the contract was
|
|
1220
|
+
// silently conditional: a batch carrying an `action` event
|
|
1221
|
+
// respected the predicate (above), while a batch carrying only
|
|
1222
|
+
// `db`/`collab`/`settings`/`screen-refresh` blew away every
|
|
1223
|
+
// ["action"] query regardless of what the app opted out of — and
|
|
1224
|
+
// an app cannot work around it, because both the prefix and this
|
|
1225
|
+
// call are framework-owned.
|
|
1226
|
+
const predicate = actionInvalidatePredicateRef.current;
|
|
1227
|
+
invalidateWithoutCancel(predicate ? { predicate } : { queryKey: ["action"] });
|
|
1220
1228
|
}
|
|
1221
1229
|
if (!hasActionEvent || hasFrameworkPrefixEvent) {
|
|
1222
1230
|
invalidateWithoutCancel({ queryKey: ["extension"] });
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
+
error?: undefined;
|
|
65
66
|
states: {
|
|
66
67
|
clientId: number;
|
|
67
68
|
state: string;
|
|
68
69
|
}[];
|
|
69
|
-
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
+
error?: undefined;
|
|
80
81
|
users: {
|
|
81
82
|
clientId: number;
|
|
82
83
|
lastSeen: number;
|
|
83
84
|
}[];
|
|
84
|
-
error?: undefined;
|
|
85
85
|
}>>;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
package/dist/db/ddl-guard.d.ts
CHANGED
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
* behaviour there.
|
|
30
30
|
*/
|
|
31
31
|
import { type DbExec } from "./client.js";
|
|
32
|
+
/**
|
|
33
|
+
* Drop the cached picture after any successful DDL, so a freshly created object
|
|
34
|
+
* is visible to the next probe rather than reported absent for the life of the
|
|
35
|
+
* process.
|
|
36
|
+
*/
|
|
37
|
+
export declare function invalidateSchemaSnapshot(injectedClient?: DbExec): void;
|
|
38
|
+
/** Test seam — the snapshots are module state, so suites must clear them. */
|
|
39
|
+
export declare function __resetSchemaSnapshotForTests(): void;
|
|
32
40
|
/**
|
|
33
41
|
* True when running against Postgres AND the given table already exists in the
|
|
34
42
|
* `public` schema. Returns `false` on SQLite (callers gate their own behaviour
|
package/dist/db/ddl-guard.js
CHANGED
|
@@ -30,6 +30,135 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { isPostgres, getDbExec } from "./client.js";
|
|
32
32
|
const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
33
|
+
/**
|
|
34
|
+
* Skip on-demand schema DDL entirely. For deployments that run real migrations,
|
|
35
|
+
* where the probe → DDL machinery below is pure cold-start latency.
|
|
36
|
+
*
|
|
37
|
+
* Implemented at the PROBE layer, not per store: every `ensureTable()` in the
|
|
38
|
+
* codebase reaches the database through these three helpers, and there are ~390
|
|
39
|
+
* call sites across ~57 stores. Reporting "present" here makes
|
|
40
|
+
* `ensureSchemaObject` a no-op without touching any of them.
|
|
41
|
+
*
|
|
42
|
+
* This does not fail closed, and cannot: the only honest signal that schema is
|
|
43
|
+
* missing is the real query failing, which it will, loudly, at the first read.
|
|
44
|
+
* Silently reporting "absent" instead would be worse — it would send every store
|
|
45
|
+
* down the DDL path this flag exists to avoid.
|
|
46
|
+
*/
|
|
47
|
+
function schemaEnsureDisabled() {
|
|
48
|
+
const raw = process.env.AGENT_NATIVE_SKIP_ENSURE_TABLES?.trim();
|
|
49
|
+
return !!raw && ["1", "true", "yes", "on"].includes(raw.toLowerCase());
|
|
50
|
+
}
|
|
51
|
+
const injectedSnapshots = new WeakMap();
|
|
52
|
+
let globalSnapshot;
|
|
53
|
+
/**
|
|
54
|
+
* Drop the cached picture after any successful DDL, so a freshly created object
|
|
55
|
+
* is visible to the next probe rather than reported absent for the life of the
|
|
56
|
+
* process.
|
|
57
|
+
*/
|
|
58
|
+
export function invalidateSchemaSnapshot(injectedClient) {
|
|
59
|
+
// The global snapshot always goes: a store may create an object through an
|
|
60
|
+
// injected client and later probe through the global one (or the reverse), so
|
|
61
|
+
// after DDL neither picture is trustworthy and both are cheap to rebuild.
|
|
62
|
+
globalSnapshot = undefined;
|
|
63
|
+
if (injectedClient)
|
|
64
|
+
injectedSnapshots.delete(injectedClient);
|
|
65
|
+
}
|
|
66
|
+
/** Test seam — the snapshots are module state, so suites must clear them. */
|
|
67
|
+
export function __resetSchemaSnapshotForTests() {
|
|
68
|
+
globalSnapshot = undefined;
|
|
69
|
+
}
|
|
70
|
+
async function loadSchemaSnapshot(client) {
|
|
71
|
+
try {
|
|
72
|
+
const [columnRows, indexRows] = await Promise.all([
|
|
73
|
+
client.execute(`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'`),
|
|
74
|
+
client.execute(`SELECT indexname FROM pg_indexes WHERE schemaname = 'public'`),
|
|
75
|
+
]);
|
|
76
|
+
// An EMPTY result is a valid snapshot (a fresh database really has nothing
|
|
77
|
+
// in `public`). A result whose rows lack the columns we asked for is not —
|
|
78
|
+
// it means something other than Postgres answered, and treating it as "these
|
|
79
|
+
// are the objects that exist" would report every table absent and send every
|
|
80
|
+
// store down the DDL path. Fall back to per-object probes instead.
|
|
81
|
+
const columnData = columnRows.rows;
|
|
82
|
+
const indexData = indexRows.rows;
|
|
83
|
+
if (columnData.length > 0 && !("table_name" in columnData[0]))
|
|
84
|
+
return null;
|
|
85
|
+
if (indexData.length > 0 && !("indexname" in indexData[0]))
|
|
86
|
+
return null;
|
|
87
|
+
const tables = new Set();
|
|
88
|
+
const columns = new Set();
|
|
89
|
+
for (const row of columnData) {
|
|
90
|
+
const table = String(row.table_name ?? "").toLowerCase();
|
|
91
|
+
const column = String(row.column_name ?? "").toLowerCase();
|
|
92
|
+
if (!table)
|
|
93
|
+
continue;
|
|
94
|
+
tables.add(table);
|
|
95
|
+
if (column)
|
|
96
|
+
columns.add(`${table}.${column}`);
|
|
97
|
+
}
|
|
98
|
+
const indexes = new Set();
|
|
99
|
+
for (const row of indexData) {
|
|
100
|
+
const name = String(row.indexname ?? "").toLowerCase();
|
|
101
|
+
if (name)
|
|
102
|
+
indexes.add(name);
|
|
103
|
+
}
|
|
104
|
+
return { tables, columns, indexes };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// `information_schema` unreadable (permissions / non-standard backend).
|
|
108
|
+
// `null` is a typed "no snapshot" the caller tells apart from an empty one:
|
|
109
|
+
// each probe falls back to its original per-object query. It does NOT mean
|
|
110
|
+
// "the schema is empty", which would make every store issue DDL it does not
|
|
111
|
+
// need. Say so once — the fallback is correct but costs a few hundred round
|
|
112
|
+
// trips per cold start, which is invisible otherwise.
|
|
113
|
+
warnIntrospectionUnavailableOnce(err);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let _warnedIntrospectionUnavailable = false;
|
|
118
|
+
function warnIntrospectionUnavailableOnce(err) {
|
|
119
|
+
if (_warnedIntrospectionUnavailable)
|
|
120
|
+
return;
|
|
121
|
+
_warnedIntrospectionUnavailable = true;
|
|
122
|
+
console.warn("[db] batched schema introspection unavailable; falling back to one probe " +
|
|
123
|
+
"per table/column/index (slower cold starts, same behaviour): " +
|
|
124
|
+
(err?.message ?? err));
|
|
125
|
+
}
|
|
126
|
+
// `loadSchemaSnapshot` resolves to `null` on failure rather than rejecting, so
|
|
127
|
+
// these need no `.catch()` — adding one would be dead code hiding that contract.
|
|
128
|
+
function schemaSnapshot(client, injected) {
|
|
129
|
+
if (injected) {
|
|
130
|
+
let pending = injectedSnapshots.get(client);
|
|
131
|
+
if (!pending) {
|
|
132
|
+
pending = loadSchemaSnapshot(client);
|
|
133
|
+
injectedSnapshots.set(client, pending);
|
|
134
|
+
}
|
|
135
|
+
return pending;
|
|
136
|
+
}
|
|
137
|
+
if (!globalSnapshot) {
|
|
138
|
+
globalSnapshot = loadSchemaSnapshot(client);
|
|
139
|
+
}
|
|
140
|
+
return globalSnapshot;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* `true`/`false` from the cached picture, or `undefined` when there is no usable
|
|
144
|
+
* snapshot and the caller must fall back to its own single-object probe.
|
|
145
|
+
*
|
|
146
|
+
* A snapshot MISS is authoritative: the snapshot lists every object in `public`,
|
|
147
|
+
* so "not in the set" means the object really is absent and the caller should
|
|
148
|
+
* proceed to DDL. That is what makes this a replacement for the per-object
|
|
149
|
+
* probe rather than a layer in front of it.
|
|
150
|
+
*/
|
|
151
|
+
async function snapshotHas(kind, key, client, injected) {
|
|
152
|
+
const snapshot = await schemaSnapshot(client, injected);
|
|
153
|
+
if (!snapshot)
|
|
154
|
+
return undefined;
|
|
155
|
+
const set = kind === "table"
|
|
156
|
+
? snapshot.tables
|
|
157
|
+
: kind === "column"
|
|
158
|
+
? snapshot.columns
|
|
159
|
+
: snapshot.indexes;
|
|
160
|
+
return set.has(key.toLowerCase());
|
|
161
|
+
}
|
|
33
162
|
/**
|
|
34
163
|
* True when running against Postgres AND the given table already exists in the
|
|
35
164
|
* `public` schema. Returns `false` on SQLite (callers gate their own behaviour
|
|
@@ -47,7 +176,12 @@ export async function pgTableExists(table, injectedClient, dialectIsPostgres) {
|
|
|
47
176
|
if (!(dialectIsPostgres ?? isPostgres()) || !PLAIN_IDENTIFIER.test(table)) {
|
|
48
177
|
return false;
|
|
49
178
|
}
|
|
179
|
+
if (schemaEnsureDisabled())
|
|
180
|
+
return true;
|
|
50
181
|
const client = injectedClient ?? getDbExec();
|
|
182
|
+
const cached = await snapshotHas("table", table, client, !!injectedClient);
|
|
183
|
+
if (cached !== undefined)
|
|
184
|
+
return cached;
|
|
51
185
|
try {
|
|
52
186
|
const { rows } = await client.execute({
|
|
53
187
|
sql: `SELECT 1 FROM information_schema.tables
|
|
@@ -77,7 +211,12 @@ export async function pgColumnExists(table, column, injectedClient) {
|
|
|
77
211
|
if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {
|
|
78
212
|
return false;
|
|
79
213
|
}
|
|
214
|
+
if (schemaEnsureDisabled())
|
|
215
|
+
return true;
|
|
80
216
|
const client = injectedClient ?? getDbExec();
|
|
217
|
+
const cached = await snapshotHas("column", `${table}.${column}`, client, !!injectedClient);
|
|
218
|
+
if (cached !== undefined)
|
|
219
|
+
return cached;
|
|
81
220
|
try {
|
|
82
221
|
const { rows } = await client.execute({
|
|
83
222
|
sql: `SELECT 1 FROM information_schema.columns
|
|
@@ -108,7 +247,12 @@ export async function pgIndexExists(indexName, injectedClient, dialectIsPostgres
|
|
|
108
247
|
!PLAIN_IDENTIFIER.test(indexName)) {
|
|
109
248
|
return false;
|
|
110
249
|
}
|
|
250
|
+
if (schemaEnsureDisabled())
|
|
251
|
+
return true;
|
|
111
252
|
const client = injectedClient ?? getDbExec();
|
|
253
|
+
const cached = await snapshotHas("index", indexName, client, !!injectedClient);
|
|
254
|
+
if (cached !== undefined)
|
|
255
|
+
return cached;
|
|
112
256
|
try {
|
|
113
257
|
const { rows } = await client.execute({
|
|
114
258
|
sql: `SELECT 1 FROM pg_indexes
|
|
@@ -162,6 +306,10 @@ export async function ensureSchemaObject(options) {
|
|
|
162
306
|
injectedClient,
|
|
163
307
|
dialectIsPostgres,
|
|
164
308
|
});
|
|
309
|
+
// The cached picture predates this object. Drop it before anyone probes again,
|
|
310
|
+
// or a sibling `ensureTable` in the same boot is told the object it just
|
|
311
|
+
// created does not exist.
|
|
312
|
+
invalidateSchemaSnapshot(injectedClient);
|
|
165
313
|
if (ran)
|
|
166
314
|
return true;
|
|
167
315
|
// The DDL was swallowed by a lock-timeout. The object is virtually always
|
package/dist/deploy/build.d.ts
CHANGED
|
@@ -94,11 +94,47 @@ export declare const NETLIFY_INTEGRATION_RECOVERY_FUNCTION_NAME = "server-integr
|
|
|
94
94
|
export declare function isIntegrationDurableDispatchDeployEnabled(): boolean;
|
|
95
95
|
export declare const NETLIFY_RECURRING_JOBS_FUNCTION_NAME = "agent-native-recurring-jobs";
|
|
96
96
|
export declare function isRecurringJobsDeployEnabled(): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Keep-warm is ON by default, and on a provisioned database that is the right
|
|
99
|
+
* default. It is the wrong default on a metered scale-to-zero tier: a
|
|
100
|
+
* once-a-minute wake means autosuspend NEVER fires, so the endpoint bills (and
|
|
101
|
+
* quota-limits) as if it were awake 100% of the time with zero users online. On
|
|
102
|
+
* a free-tier Neon that exhausts the compute quota and the database then
|
|
103
|
+
* hard-blocks every read mid-session — the symptom is a dead app, not a slow one.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isKeepWarmDeployEnabled(): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The background warm is a separate, much more expensive knob than the server
|
|
108
|
+
* warm and gets its own switch. Warming `server` is one health request; warming
|
|
109
|
+
* the `-background` Lambda is a *fresh container*, so every ping pays the full
|
|
110
|
+
* on-demand `ensureTable()` schema-probe fan-out (hundreds of
|
|
111
|
+
* `information_schema` round trips) before it does anything. At the default
|
|
112
|
+
* cadence that is ~1,440 manufactured cold starts a day that no user asked for.
|
|
113
|
+
* Turn this off to keep dispatch-latency protection for the server function
|
|
114
|
+
* while dropping the probe storm.
|
|
115
|
+
*/
|
|
116
|
+
export declare function isKeepWarmBackgroundDeployEnabled(): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Cadence for the keep-warm schedule, overridable with
|
|
119
|
+
* `AGENT_NATIVE_KEEP_WARM_SCHEDULE` (standard 5-field cron).
|
|
120
|
+
*
|
|
121
|
+
* An unparseable value THROWS rather than falling back to the default. Falling
|
|
122
|
+
* back would leave an operator who set this specifically to stop burning
|
|
123
|
+
* database quota still burning it at the original once-a-minute cadence, with a
|
|
124
|
+
* successful build and nothing in the log to say the value was ignored.
|
|
125
|
+
*/
|
|
126
|
+
export declare function resolveKeepWarmSchedule(): string;
|
|
97
127
|
/**
|
|
98
128
|
* Emit a site-local Netlify Scheduled Function that wakes the public server
|
|
99
129
|
* function and its database every minute. GitHub Actions schedules can be
|
|
100
130
|
* delayed by tens of minutes, which is longer than a scale-to-zero database's
|
|
101
131
|
* autosuspend window and leaves the next visitor to pay the cold-start cost.
|
|
132
|
+
*
|
|
133
|
+
* Both halves are opt-out and the cadence is configurable — see
|
|
134
|
+
* `isKeepWarmDeployEnabled`, `isKeepWarmBackgroundDeployEnabled`, and
|
|
135
|
+
* `resolveKeepWarmSchedule`. The tradeoff this function encodes is next-visitor
|
|
136
|
+
* latency against database awake-time, and only the deployment knows which of
|
|
137
|
+
* those it is paying for.
|
|
102
138
|
*/
|
|
103
139
|
export declare function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd: string): void;
|
|
104
140
|
/**
|
package/dist/deploy/build.js
CHANGED
|
@@ -20,6 +20,7 @@ import path from "path";
|
|
|
20
20
|
import { fileURLToPath } from "url";
|
|
21
21
|
import { AGENT_BACKGROUND_FUNCTION_NAME, AGENT_BACKGROUND_FUNCTION_URL_PATH, AGENT_BACKGROUND_PROCESSOR_A2A, AGENT_BACKGROUND_PROCESSOR_FIELD, AGENT_BACKGROUND_PROCESSOR_INTEGRATION, AGENT_BACKGROUND_PROCESSOR_ROUTE, AGENT_BACKGROUND_PROCESSOR_ROUTE_FIELD, AGENT_CHAT_PROCESS_RUN_PATH, isDurableBackgroundFlagExplicitlyDisabled, } from "../agent/durable-background.js";
|
|
22
22
|
import { INTEGRATION_RECOVERY_RUNTIME_MARKER, INTEGRATION_RETRY_SWEEP_PATH, INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT, isIntegrationDurableDispatchConfigured, } from "../integrations/integration-durable-dispatch-config.js";
|
|
23
|
+
import { isValidCron } from "../jobs/cron.js";
|
|
23
24
|
import { RECURRING_JOBS_SWEEP_PATH, RECURRING_JOBS_SWEEP_TOKEN_SUBJECT, } from "../jobs/scheduler-dispatch.js";
|
|
24
25
|
import { normalizeAppBasePath } from "../server/app-base-path.js";
|
|
25
26
|
import { DEFAULT_SPECULATION_RULES_PATH, resolveSsrCacheHeaders, } from "../shared/cache-control.js";
|
|
@@ -2196,17 +2197,85 @@ export function isIntegrationDurableDispatchDeployEnabled() {
|
|
|
2196
2197
|
}
|
|
2197
2198
|
const NETLIFY_KEEP_WARM_FUNCTION_NAME = "agent-native-keep-warm";
|
|
2198
2199
|
export const NETLIFY_RECURRING_JOBS_FUNCTION_NAME = "agent-native-recurring-jobs";
|
|
2200
|
+
/** Shared shape for the `AGENT_NATIVE_DISABLE_*` build kill switches. */
|
|
2201
|
+
function isDisabledByEnv(name) {
|
|
2202
|
+
const value = process.env[name]?.trim();
|
|
2203
|
+
return !!value && ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
2204
|
+
}
|
|
2199
2205
|
export function isRecurringJobsDeployEnabled() {
|
|
2200
|
-
|
|
2201
|
-
|
|
2206
|
+
return !isDisabledByEnv("AGENT_NATIVE_DISABLE_RECURRING_JOBS");
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Keep-warm is ON by default, and on a provisioned database that is the right
|
|
2210
|
+
* default. It is the wrong default on a metered scale-to-zero tier: a
|
|
2211
|
+
* once-a-minute wake means autosuspend NEVER fires, so the endpoint bills (and
|
|
2212
|
+
* quota-limits) as if it were awake 100% of the time with zero users online. On
|
|
2213
|
+
* a free-tier Neon that exhausts the compute quota and the database then
|
|
2214
|
+
* hard-blocks every read mid-session — the symptom is a dead app, not a slow one.
|
|
2215
|
+
*/
|
|
2216
|
+
export function isKeepWarmDeployEnabled() {
|
|
2217
|
+
return !isDisabledByEnv("AGENT_NATIVE_DISABLE_KEEP_WARM");
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* The background warm is a separate, much more expensive knob than the server
|
|
2221
|
+
* warm and gets its own switch. Warming `server` is one health request; warming
|
|
2222
|
+
* the `-background` Lambda is a *fresh container*, so every ping pays the full
|
|
2223
|
+
* on-demand `ensureTable()` schema-probe fan-out (hundreds of
|
|
2224
|
+
* `information_schema` round trips) before it does anything. At the default
|
|
2225
|
+
* cadence that is ~1,440 manufactured cold starts a day that no user asked for.
|
|
2226
|
+
* Turn this off to keep dispatch-latency protection for the server function
|
|
2227
|
+
* while dropping the probe storm.
|
|
2228
|
+
*/
|
|
2229
|
+
export function isKeepWarmBackgroundDeployEnabled() {
|
|
2230
|
+
return !isDisabledByEnv("AGENT_NATIVE_DISABLE_KEEP_WARM_BACKGROUND");
|
|
2231
|
+
}
|
|
2232
|
+
const DEFAULT_KEEP_WARM_SCHEDULE = "* * * * *";
|
|
2233
|
+
/**
|
|
2234
|
+
* Cadence for the keep-warm schedule, overridable with
|
|
2235
|
+
* `AGENT_NATIVE_KEEP_WARM_SCHEDULE` (standard 5-field cron).
|
|
2236
|
+
*
|
|
2237
|
+
* An unparseable value THROWS rather than falling back to the default. Falling
|
|
2238
|
+
* back would leave an operator who set this specifically to stop burning
|
|
2239
|
+
* database quota still burning it at the original once-a-minute cadence, with a
|
|
2240
|
+
* successful build and nothing in the log to say the value was ignored.
|
|
2241
|
+
*/
|
|
2242
|
+
export function resolveKeepWarmSchedule() {
|
|
2243
|
+
const raw = process.env.AGENT_NATIVE_KEEP_WARM_SCHEDULE?.trim();
|
|
2244
|
+
if (!raw)
|
|
2245
|
+
return DEFAULT_KEEP_WARM_SCHEDULE;
|
|
2246
|
+
const fields = raw.split(/\s+/);
|
|
2247
|
+
// The field count is checked separately from the field values because
|
|
2248
|
+
// `isValidCron` also accepts 6-field (seconds) and `@daily` forms that
|
|
2249
|
+
// Netlify's scheduler does not; "5 fields" is the narrower contract.
|
|
2250
|
+
if (fields.length !== 5 || !isValidCron(raw)) {
|
|
2251
|
+
throw new Error(`AGENT_NATIVE_KEEP_WARM_SCHEDULE must be a 5-field cron expression ` +
|
|
2252
|
+
`(minute hour day month weekday); got "${raw}" (${fields.length} field(s)). ` +
|
|
2253
|
+
`Example: "*/5 * * * *" for every five minutes.`);
|
|
2254
|
+
}
|
|
2255
|
+
return raw;
|
|
2202
2256
|
}
|
|
2203
2257
|
/**
|
|
2204
2258
|
* Emit a site-local Netlify Scheduled Function that wakes the public server
|
|
2205
2259
|
* function and its database every minute. GitHub Actions schedules can be
|
|
2206
2260
|
* delayed by tens of minutes, which is longer than a scale-to-zero database's
|
|
2207
2261
|
* autosuspend window and leaves the next visitor to pay the cold-start cost.
|
|
2262
|
+
*
|
|
2263
|
+
* Both halves are opt-out and the cadence is configurable — see
|
|
2264
|
+
* `isKeepWarmDeployEnabled`, `isKeepWarmBackgroundDeployEnabled`, and
|
|
2265
|
+
* `resolveKeepWarmSchedule`. The tradeoff this function encodes is next-visitor
|
|
2266
|
+
* latency against database awake-time, and only the deployment knows which of
|
|
2267
|
+
* those it is paying for.
|
|
2208
2268
|
*/
|
|
2209
2269
|
export function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd) {
|
|
2270
|
+
if (!isKeepWarmDeployEnabled()) {
|
|
2271
|
+
console.log("[build] Keep-warm emit skipped: AGENT_NATIVE_DISABLE_KEEP_WARM is set. " +
|
|
2272
|
+
"The database is free to autosuspend; the next visitor after an idle " +
|
|
2273
|
+
"period pays its cold start.");
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
// Resolved before anything is removed or written, so a bad cron fails the
|
|
2277
|
+
// build rather than leaving a wiped/half-emitted function directory behind.
|
|
2278
|
+
const keepWarmSchedule = resolveKeepWarmSchedule();
|
|
2210
2279
|
const internalDir = path.join(projectCwd, ".netlify", "functions-internal");
|
|
2211
2280
|
const serverBundle = path.join(internalDir, "server", "main.mjs");
|
|
2212
2281
|
if (!fs.existsSync(serverBundle)) {
|
|
@@ -2223,7 +2292,9 @@ export function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd) {
|
|
|
2223
2292
|
// rejected by the `_process-run` route before any DB work, so this only keeps
|
|
2224
2293
|
// the container alive.
|
|
2225
2294
|
const backgroundEntryPath = path.join(internalDir, AGENT_BACKGROUND_FUNCTION_NAME, `${AGENT_BACKGROUND_FUNCTION_NAME}.mjs`);
|
|
2226
|
-
const backgroundWarmPath =
|
|
2295
|
+
const backgroundWarmPath = isKeepWarmBackgroundDeployEnabled() &&
|
|
2296
|
+
isDurableBackgroundEmitRequired() &&
|
|
2297
|
+
fs.existsSync(backgroundEntryPath)
|
|
2227
2298
|
? JSON.stringify(AGENT_BACKGROUND_FUNCTION_URL_PATH)
|
|
2228
2299
|
: "null";
|
|
2229
2300
|
const entry = `const HEALTH_PATH = "/_agent-native/health";
|
|
@@ -2288,12 +2359,14 @@ export default async function handler(request) {
|
|
|
2288
2359
|
export const config = {
|
|
2289
2360
|
name: "agent-native server keep warm",
|
|
2290
2361
|
generator: "agent-native build",
|
|
2291
|
-
schedule:
|
|
2362
|
+
schedule: ${JSON.stringify(keepWarmSchedule)},
|
|
2292
2363
|
nodeBundler: "none",
|
|
2293
2364
|
};
|
|
2294
2365
|
`;
|
|
2295
2366
|
fs.writeFileSync(path.join(dest, `${NETLIFY_KEEP_WARM_FUNCTION_NAME}.mjs`), entry);
|
|
2296
|
-
console.log(`[build] Emitted Netlify scheduled keep-warm function
|
|
2367
|
+
console.log(`[build] Emitted Netlify scheduled keep-warm function ` +
|
|
2368
|
+
`"${NETLIFY_KEEP_WARM_FUNCTION_NAME}" (schedule "${keepWarmSchedule}", ` +
|
|
2369
|
+
`background warm ${backgroundWarmPath === "null" ? "off" : "on"}).`);
|
|
2297
2370
|
}
|
|
2298
2371
|
/**
|
|
2299
2372
|
* Emit the durable recurring-job trigger. Netlify's scheduled function only
|