@desplega.ai/agent-swarm 1.129.0 → 1.131.0
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/README.md +2 -2
- package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
- package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
- package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
- package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
- package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
- package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
- package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
- package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
- package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
- package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
- package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
- package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
- package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
- package/dist/cli-3fj60etf.js +153 -0
- package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
- package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
- package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
- package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
- package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
- package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
- package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
- package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
- package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
- package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
- package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
- package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
- package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
- package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
- package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
- package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
- package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
- package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
- package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
- package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
- package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
- package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
- package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
- package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
- package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
- package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
- package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
- package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
- package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
- package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
- package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
- package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
- package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
- package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
- package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
- package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
- package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
- package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
- package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
- package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
- package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
- package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
- package/dist/cli-zsspvvm2.js +490 -0
- package/dist/cli.js +27 -25
- package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
- package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
- package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
- package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
- package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
- package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
- package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
- package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
- package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
- package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
- package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
- package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
- package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
- package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
- package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
- package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
- package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
- package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
- package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
- package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
- package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
- package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
- package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
- package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
- package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
- package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
- package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
- package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
- package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
- package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
- package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
- package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
- package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
- package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
- package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
- package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
- package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
- package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
- package/openapi.json +34039 -11036
- package/package.json +8 -6
- package/src/apps/definition.ts +368 -26
- package/src/apps/row-store.ts +66 -10
- package/src/apps/schema-migrate.ts +95 -1
- package/src/apps/store.ts +13 -4
- package/src/apps/sync-run-as.ts +18 -0
- package/src/apps/sync.ts +934 -0
- package/src/apps/version.ts +16 -2
- package/src/assets/key.ts +8 -1
- package/src/be/asset-key-audit.ts +3 -1
- package/src/be/db-queries/mcp-oauth.ts +297 -24
- package/src/be/db.ts +42 -1
- package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
- package/src/be/scripts/db.ts +5 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
- package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
- package/src/be/seed-scripts/index.ts +18 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/http/active-sessions.ts +26 -18
- package/src/http/agents.ts +94 -27
- package/src/http/api-keys.ts +91 -17
- package/src/http/approval-requests.ts +87 -10
- package/src/http/apps.ts +361 -53
- package/src/http/assets.ts +165 -31
- package/src/http/budgets.ts +7 -5
- package/src/http/codex-oauth-keep-warm.ts +21 -3
- package/src/http/config.ts +46 -16
- package/src/http/context.ts +33 -6
- package/src/http/ecosystem.ts +20 -6
- package/src/http/events.ts +24 -9
- package/src/http/favorites.ts +20 -6
- package/src/http/fs.ts +60 -17
- package/src/http/heartbeat.ts +18 -4
- package/src/http/inbox-state.ts +12 -6
- package/src/http/integrations.ts +25 -5
- package/src/http/kv.ts +36 -15
- package/src/http/mcp-bridge.ts +5 -1
- package/src/http/mcp-oauth.ts +332 -17
- package/src/http/mcp-servers.ts +56 -18
- package/src/http/memory.ts +282 -29
- package/src/http/metrics.ts +80 -17
- package/src/http/oauth-callback.ts +10 -4
- package/src/http/oauth-generic.ts +5 -1
- package/src/http/oauth-locks.ts +6 -3
- package/src/http/openapi.ts +57 -9
- package/src/http/page-proxy.ts +25 -5
- package/src/http/pages-public.ts +37 -20
- package/src/http/pages.ts +93 -28
- package/src/http/poll.ts +89 -5
- package/src/http/pricing.ts +9 -6
- package/src/http/prompt-templates.ts +85 -21
- package/src/http/route-def.ts +67 -4
- package/src/http/schedules.ts +63 -14
- package/src/http/script-connection-proxy.ts +30 -4
- package/src/http/script-connections.ts +393 -43
- package/src/http/script-runs.ts +73 -19
- package/src/http/scripts.ts +257 -39
- package/src/http/session-data.ts +87 -14
- package/src/http/sessions.ts +80 -5
- package/src/http/skills.ts +115 -35
- package/src/http/stats.ts +94 -13
- package/src/http/task-templates.ts +6 -4
- package/src/http/tasks.ts +230 -61
- package/src/http/trackers/jira.ts +90 -30
- package/src/http/trackers/linear.ts +46 -12
- package/src/http/users.ts +162 -25
- package/src/http/utils.ts +12 -2
- package/src/http/webhooks.ts +30 -16
- package/src/http/workflow-events.ts +18 -5
- package/src/http/workflows.ts +128 -39
- package/src/http/x.ts +37 -6
- package/src/oauth/ensure-mcp-token.ts +16 -1
- package/src/oauth/mcp-wrapper.ts +281 -26
- package/src/providers/pi-mono-adapter.ts +1 -1
- package/src/scripts-runtime/credential-broker/README.md +1 -1
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/stdlib/fetch.ts +6 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tests/apps-rbac.test.ts +110 -0
- package/src/tests/apps-spike2.test.ts +4 -1
- package/src/tests/apps-sync-engine.test.ts +2540 -0
- package/src/tests/apps-sync.test.ts +1627 -0
- package/src/tests/asset-key-api.test.ts +48 -2
- package/src/tests/asset-key-invariants.test.ts +92 -0
- package/src/tests/asset-key-migration.test.ts +174 -8
- package/src/tests/asset-key-normalization.test.ts +2 -0
- package/src/tests/credential-broker.test.ts +27 -2
- package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
- package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
- package/src/tests/mcp-oauth-queries.test.ts +153 -0
- package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
- package/src/tests/memory-http-recall-gating.test.ts +2 -0
- package/src/tests/openapi-response-contract.test.ts +138 -0
- package/src/tests/rbac-admission-e2e.test.ts +68 -0
- package/src/tests/script-connections.test.ts +3 -0
- package/src/tests/scripts-runtime-fetch.test.ts +121 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/swarm-tool-result-gate.test.ts +32 -0
- package/src/tests/task-working-dir.test.ts +8 -4
- package/src/tests/whoami-route.test.ts +127 -0
- package/src/tests/workflow-engine-v2.test.ts +80 -7
- package/src/tests/workflow-swarm-script.test.ts +104 -0
- package/src/tools/app-get.ts +10 -2
- package/src/tools/app-rollback.ts +1 -0
- package/src/tools/app-sync.ts +112 -0
- package/src/tools/script-common.ts +20 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +11 -0
- package/src/tools/workflows/create-workflow.ts +4 -1
- package/src/types.ts +1394 -1206
- package/src/utils/template.ts +8 -1
- package/src/utils/zod-openapi.ts +11 -0
- package/src/workflows/engine.ts +27 -4
- package/src/workflows/executors/swarm-script.ts +28 -0
- package/src/workflows/retry-poller.ts +19 -2
- package/templates/skills/apps/content.md +94 -5
- package/templates/skills/asset-namespaces/content.md +2 -2
- package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
- package/templates/skills/scheduled-task-resilience/config.json +14 -0
- package/templates/skills/scheduled-task-resilience/content.md +97 -0
- package/dist/cli-7wmybrt5.js +0 -115
- package/dist/cli-d91pvmq1.js +0 -14
package/src/apps/sync.ts
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { deleteKv, getAllTasks, getKv, type TaskFilters, upsertKv } from "../be/db";
|
|
3
|
+
import { listScriptConnections } from "../be/script-connections";
|
|
4
|
+
import { getScriptById } from "../be/scripts/db";
|
|
5
|
+
import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
|
|
6
|
+
import { type AgentTask, AgentTaskStatusSchema } from "../types";
|
|
7
|
+
import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
8
|
+
import {
|
|
9
|
+
type AppDefinition,
|
|
10
|
+
type AppValidationIssue,
|
|
11
|
+
type ColumnDef,
|
|
12
|
+
isIso8601Date,
|
|
13
|
+
type ModelDef,
|
|
14
|
+
type SourceDef,
|
|
15
|
+
type SourceTransform,
|
|
16
|
+
} from "./definition";
|
|
17
|
+
import {
|
|
18
|
+
type AppRow,
|
|
19
|
+
appsNamespace,
|
|
20
|
+
createAppRowUnlocked,
|
|
21
|
+
listAllAppRowsForMigrationUnlocked,
|
|
22
|
+
patchAppRowUnlocked,
|
|
23
|
+
withMutationLock,
|
|
24
|
+
} from "./row-store";
|
|
25
|
+
import { appDefinitionNeedsRepair, getApp } from "./store";
|
|
26
|
+
import { resolveSyncRunAs } from "./sync-run-as";
|
|
27
|
+
|
|
28
|
+
/** One record a source pull produced: an identity plus its raw field bag. */
|
|
29
|
+
export type SourceRecord = { key: string; fields: Record<string, unknown> };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `complete: false` means the pull window may have missed records, so the
|
|
33
|
+
* caller must NOT sweep unseen rows stale.
|
|
34
|
+
*/
|
|
35
|
+
export type PullResult = { records: SourceRecord[]; complete: boolean; warnings: string[] };
|
|
36
|
+
|
|
37
|
+
export type SyncPassResult = {
|
|
38
|
+
model: string;
|
|
39
|
+
source: string;
|
|
40
|
+
connector: "script" | "swarm-tasks";
|
|
41
|
+
pulled: number;
|
|
42
|
+
created: number;
|
|
43
|
+
updated: number;
|
|
44
|
+
refreshed: number;
|
|
45
|
+
/** Source-owned rows this pass wrote nothing to: unseen and already stale, or unseen with the sweep skipped. */
|
|
46
|
+
unchanged: number;
|
|
47
|
+
markedStale: number;
|
|
48
|
+
staleSweepSkipped?: boolean;
|
|
49
|
+
warnings: string[];
|
|
50
|
+
durationMs: number;
|
|
51
|
+
invokedBy?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
/** Set together when single-flight short-circuited this trigger — no pull, no writes. */
|
|
54
|
+
skipped?: true;
|
|
55
|
+
alreadyRunning?: true;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `ok` is false when any pass errored, or when the request never resolved to a
|
|
60
|
+
* runnable pair — in which case `passes` is empty and `issues` carries
|
|
61
|
+
* path-bearing reasons the doors (HTTP 400 / `toolErr`) can render verbatim.
|
|
62
|
+
*/
|
|
63
|
+
export type AppSyncResult = {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
passes: SyncPassResult[];
|
|
66
|
+
issues?: AppValidationIssue[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Last-pass state per pair. No history — the current state is the whole story. */
|
|
70
|
+
export type AppSyncStatus = {
|
|
71
|
+
lastStartedAt: string;
|
|
72
|
+
lastFinishedAt: string;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
created: number;
|
|
75
|
+
updated: number;
|
|
76
|
+
refreshed: number;
|
|
77
|
+
markedStale: number;
|
|
78
|
+
error?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const MAX_PULL_RECORDS = 500;
|
|
82
|
+
const MAX_PASS_WARNINGS = 20;
|
|
83
|
+
const MAX_TASK_LIMIT = 200;
|
|
84
|
+
const DEFAULT_TASK_LIMIT = 100;
|
|
85
|
+
const MAX_TASK_PROMPT_CHARS = 1000;
|
|
86
|
+
const MAX_ERROR_DETAIL_CHARS = 500;
|
|
87
|
+
|
|
88
|
+
/** A pass failure with a message meant for an operator; never carries row churn. */
|
|
89
|
+
class SyncPassError extends Error {
|
|
90
|
+
constructor(message: string) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "SyncPassError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Transform could not produce a value for this column — the column goes null. */
|
|
97
|
+
class TransformError extends Error {}
|
|
98
|
+
|
|
99
|
+
function warn(warnings: string[], message: string): void {
|
|
100
|
+
// Hard cap: a broken source must not turn one pass into an unbounded payload.
|
|
101
|
+
if (warnings.length < MAX_PASS_WARNINGS) warnings.push(message);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Sync status (KV)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Full KV path is `apps:<appId>:sync-status:<model>:<source>` — the reserved
|
|
110
|
+
* `apps:*` namespace plus this key. Writes go through the row-store's own
|
|
111
|
+
* internal KV path (`src/be/db`), which the generic KV choke points guard.
|
|
112
|
+
*/
|
|
113
|
+
function syncStatusKey(model: string, source: string): string {
|
|
114
|
+
return `sync-status:${model}:${source}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeSyncStatus(appId: string, pass: SyncPassResult, lastStartedAt: string): void {
|
|
118
|
+
// A pass can outlive its app: deletion purges the apps:<id> namespace while
|
|
119
|
+
// a pull is in flight, and writing here would resurrect it as an orphan.
|
|
120
|
+
if (!getApp(appId)) return;
|
|
121
|
+
const status: AppSyncStatus = {
|
|
122
|
+
lastStartedAt,
|
|
123
|
+
lastFinishedAt: new Date().toISOString(),
|
|
124
|
+
ok: pass.error === undefined,
|
|
125
|
+
created: pass.created,
|
|
126
|
+
updated: pass.updated,
|
|
127
|
+
refreshed: pass.refreshed,
|
|
128
|
+
markedStale: pass.markedStale,
|
|
129
|
+
...(pass.error === undefined ? {} : { error: pass.error }),
|
|
130
|
+
};
|
|
131
|
+
upsertKv({
|
|
132
|
+
namespace: appsNamespace(appId),
|
|
133
|
+
key: syncStatusKey(pass.model, pass.source),
|
|
134
|
+
value: status,
|
|
135
|
+
valueType: "json",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Last completed pass for a pair, or null when none has run. */
|
|
140
|
+
export function getAppSyncStatus(
|
|
141
|
+
appId: string,
|
|
142
|
+
model: string,
|
|
143
|
+
source: string,
|
|
144
|
+
): AppSyncStatus | null {
|
|
145
|
+
const entry = getKv(appsNamespace(appId), syncStatusKey(model, source));
|
|
146
|
+
const value = entry?.value;
|
|
147
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
148
|
+
return value as AppSyncStatus;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every declared (model x source) pair's last-pass status, keyed
|
|
153
|
+
* `<model>:<source>` — the per-source freshness surface the app payload and
|
|
154
|
+
* `app-get` expose so UI/agents can render "last synced / last error".
|
|
155
|
+
*/
|
|
156
|
+
export function collectAppSyncStatus(appId: string): Record<string, AppSyncStatus> {
|
|
157
|
+
const statuses: Record<string, AppSyncStatus> = {};
|
|
158
|
+
const app = getApp(appId);
|
|
159
|
+
if (!app || appDefinitionNeedsRepair(app)) return statuses;
|
|
160
|
+
for (const [modelName, model] of Object.entries(app.definition.models)) {
|
|
161
|
+
for (const sourceName of Object.keys(model.sources ?? {})) {
|
|
162
|
+
const status = getAppSyncStatus(appId, modelName, sourceName);
|
|
163
|
+
if (status) statuses[`${modelName}:${sourceName}`] = status;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return statuses;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Delete the per-pair status for every (model x source) whose pull or
|
|
171
|
+
* projection dependencies changed between two definitions, or whose pair is
|
|
172
|
+
* gone: the stored freshness described the OLD configuration, and presenting
|
|
173
|
+
* it for the new one would claim a pass that never ran.
|
|
174
|
+
*/
|
|
175
|
+
export function invalidateChangedSyncStatus(
|
|
176
|
+
appId: string,
|
|
177
|
+
previous: AppDefinition | undefined,
|
|
178
|
+
next: AppDefinition,
|
|
179
|
+
): void {
|
|
180
|
+
if (!previous) return;
|
|
181
|
+
for (const [modelName, oldModel] of Object.entries(previous.models)) {
|
|
182
|
+
for (const [sourceName, oldSource] of Object.entries(oldModel.sources ?? {})) {
|
|
183
|
+
const nextModel = next.models[modelName];
|
|
184
|
+
const nextSource = nextModel?.sources?.[sourceName];
|
|
185
|
+
const unchanged =
|
|
186
|
+
nextModel !== undefined &&
|
|
187
|
+
nextSource !== undefined &&
|
|
188
|
+
pairFingerprint(nextModel, sourceName, nextSource) ===
|
|
189
|
+
pairFingerprint(oldModel, sourceName, oldSource);
|
|
190
|
+
if (!unchanged) deleteKv(appsNamespace(appId), syncStatusKey(modelName, sourceName));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Pull: script connector
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The canonical sync-script contract: a bare array is a complete snapshot;
|
|
201
|
+
* the object form may declare an incomplete window.
|
|
202
|
+
*/
|
|
203
|
+
const SourceRecordSchema = z.object({
|
|
204
|
+
key: z.union([z.string().min(1), z.number()]).transform(String),
|
|
205
|
+
fields: z.record(z.string(), z.unknown()),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const PullPayloadSchema = z.union([
|
|
209
|
+
z.array(SourceRecordSchema),
|
|
210
|
+
z.object({ records: z.array(SourceRecordSchema), complete: z.boolean().optional() }),
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
function scriptFailure(output: {
|
|
214
|
+
exitCode: number;
|
|
215
|
+
error?: string;
|
|
216
|
+
runtimeError?: { name: string; message: string };
|
|
217
|
+
stderr: string;
|
|
218
|
+
}): string | undefined {
|
|
219
|
+
if (output.exitCode === 0 && !output.error && !output.runtimeError) return undefined;
|
|
220
|
+
const base = output.runtimeError
|
|
221
|
+
? `${output.runtimeError.name}: ${output.runtimeError.message}`
|
|
222
|
+
: (output.error ?? `script exited with code ${output.exitCode}`);
|
|
223
|
+
// stderr is a prime secret carrier — scrub BEFORE truncating, or a secret
|
|
224
|
+
// straddling the cap loses its suffix and defeats exact-value redaction.
|
|
225
|
+
const stderr = scrubSecrets(output.stderr).trim().slice(0, MAX_ERROR_DETAIL_CHARS);
|
|
226
|
+
return stderr.length > 0 ? `${base} — ${stderr}` : base;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parsePullPayload(payload: unknown, warnings: string[]): PullResult {
|
|
230
|
+
const parsed = PullPayloadSchema.safeParse(payload);
|
|
231
|
+
if (!parsed.success) {
|
|
232
|
+
const detail = parsed.error.issues
|
|
233
|
+
.slice(0, 3)
|
|
234
|
+
.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
|
|
235
|
+
.join("; ");
|
|
236
|
+
throw new SyncPassError(
|
|
237
|
+
`source script returned an invalid payload — expected Array<{key, fields}> or {records, complete?} (${detail})`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
// A bare array is a complete snapshot by contract.
|
|
241
|
+
let records = Array.isArray(parsed.data) ? parsed.data : parsed.data.records;
|
|
242
|
+
let complete = Array.isArray(parsed.data) ? true : (parsed.data.complete ?? true);
|
|
243
|
+
if (records.length > MAX_PULL_RECORDS) {
|
|
244
|
+
warn(
|
|
245
|
+
warnings,
|
|
246
|
+
`source returned ${records.length} records; truncated to the ${MAX_PULL_RECORDS}-record cap and the stale sweep is skipped`,
|
|
247
|
+
);
|
|
248
|
+
records = records.slice(0, MAX_PULL_RECORDS);
|
|
249
|
+
complete = false;
|
|
250
|
+
}
|
|
251
|
+
return { records, complete, warnings };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function pullFromScript(args: {
|
|
255
|
+
appId: string;
|
|
256
|
+
model: string;
|
|
257
|
+
sourceName: string;
|
|
258
|
+
source: Extract<SourceDef, { connector: "script" }>;
|
|
259
|
+
}): Promise<PullResult> {
|
|
260
|
+
const warnings: string[] = [];
|
|
261
|
+
const { source } = args;
|
|
262
|
+
const script = getScriptById(source.scriptId);
|
|
263
|
+
if (!script) throw new SyncPassError(`script "${source.scriptId}" not found`);
|
|
264
|
+
const agentId = resolveSyncRunAs(script);
|
|
265
|
+
|
|
266
|
+
// Re-run the definition-time connection check at runtime: a connection
|
|
267
|
+
// disabled after the write must fail the pass before the script is invoked.
|
|
268
|
+
if (source.connection !== undefined) {
|
|
269
|
+
const reachable = listScriptConnections({ agentId });
|
|
270
|
+
if (!reachable.some((connection) => connection.slug === source.connection)) {
|
|
271
|
+
throw new SyncPassError(
|
|
272
|
+
`connection "${source.connection}" not found or disabled for the sync run-as identity`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// runSavedScriptAsAgent is the entire credential story: egress secrets,
|
|
278
|
+
// ctx.api.<slug> and ctx.mcp.<slug> for this identity. The engine never
|
|
279
|
+
// reads, resolves, or forwards secret material.
|
|
280
|
+
const output = await runSavedScriptAsAgent({
|
|
281
|
+
script,
|
|
282
|
+
agentId,
|
|
283
|
+
input: {
|
|
284
|
+
...source.args,
|
|
285
|
+
app: { id: args.appId },
|
|
286
|
+
model: args.model,
|
|
287
|
+
source: args.sourceName,
|
|
288
|
+
...(source.connection ? { connection: source.connection } : {}),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const failure = scriptFailure(output);
|
|
292
|
+
if (failure) throw new SyncPassError(failure);
|
|
293
|
+
return parsePullPayload(output.result, warnings);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Pull: swarm-tasks connector
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
const TASK_CONFIG_KEYS = new Set([
|
|
301
|
+
"status",
|
|
302
|
+
"agentId",
|
|
303
|
+
"tags",
|
|
304
|
+
"assetKey",
|
|
305
|
+
"limit",
|
|
306
|
+
"includeHeartbeat",
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
function commaList(value: unknown): string[] {
|
|
310
|
+
return String(value)
|
|
311
|
+
.split(",")
|
|
312
|
+
.map((entry) => entry.trim())
|
|
313
|
+
.filter((entry) => entry.length > 0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function taskLimit(raw: unknown, warnings: string[]): number {
|
|
317
|
+
if (raw === undefined) return DEFAULT_TASK_LIMIT;
|
|
318
|
+
const parsed = Math.trunc(Number(raw));
|
|
319
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
320
|
+
warn(
|
|
321
|
+
warnings,
|
|
322
|
+
`config.limit "${String(raw)}" is not a positive integer; using ${DEFAULT_TASK_LIMIT}`,
|
|
323
|
+
);
|
|
324
|
+
return DEFAULT_TASK_LIMIT;
|
|
325
|
+
}
|
|
326
|
+
if (parsed > MAX_TASK_LIMIT) {
|
|
327
|
+
warn(
|
|
328
|
+
warnings,
|
|
329
|
+
`config.limit ${parsed} exceeds the ${MAX_TASK_LIMIT} cap; using ${MAX_TASK_LIMIT}`,
|
|
330
|
+
);
|
|
331
|
+
return MAX_TASK_LIMIT;
|
|
332
|
+
}
|
|
333
|
+
return parsed;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function taskRecord(task: AgentTask): SourceRecord {
|
|
337
|
+
return {
|
|
338
|
+
key: task.id,
|
|
339
|
+
fields: {
|
|
340
|
+
id: task.id,
|
|
341
|
+
status: task.status,
|
|
342
|
+
// Scrub BEFORE truncating: a secret straddling the cap would otherwise
|
|
343
|
+
// lose its suffix and defeat exact-value redaction downstream.
|
|
344
|
+
prompt: scrubSecrets(task.task).slice(0, MAX_TASK_PROMPT_CHARS),
|
|
345
|
+
source: task.source,
|
|
346
|
+
agentId: task.agentId,
|
|
347
|
+
tags: task.tags,
|
|
348
|
+
priority: task.priority,
|
|
349
|
+
createdAt: task.createdAt,
|
|
350
|
+
updatedAt: task.lastUpdatedAt,
|
|
351
|
+
vcsProvider: task.vcsProvider,
|
|
352
|
+
vcsNumber: task.vcsNumber,
|
|
353
|
+
vcsUrl: task.vcsUrl,
|
|
354
|
+
vcsAuthor: task.vcsAuthor,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function pullFromSwarmTasks(
|
|
360
|
+
source: Extract<SourceDef, { connector: "swarm-tasks" }>,
|
|
361
|
+
invokedBy?: string,
|
|
362
|
+
): PullResult {
|
|
363
|
+
const warnings: string[] = [];
|
|
364
|
+
const config = source.config ?? {};
|
|
365
|
+
for (const key of Object.keys(config)) {
|
|
366
|
+
if (!TASK_CONFIG_KEYS.has(key)) {
|
|
367
|
+
warn(
|
|
368
|
+
warnings,
|
|
369
|
+
`config key "${key}" is not supported by the swarm-tasks connector and was ignored`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const limit = taskLimit(config.limit, warnings);
|
|
375
|
+
const filters: TaskFilters = {
|
|
376
|
+
limit,
|
|
377
|
+
includeHeartbeat: config.includeHeartbeat === true || config.includeHeartbeat === "true",
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (config.status !== undefined) {
|
|
381
|
+
const statuses = commaList(config.status).map((token) => {
|
|
382
|
+
const parsed = AgentTaskStatusSchema.safeParse(token);
|
|
383
|
+
if (!parsed.success) throw new SyncPassError(`config.status: unknown task status "${token}"`);
|
|
384
|
+
return parsed.data;
|
|
385
|
+
});
|
|
386
|
+
// Fail CLOSED like agentId/assetKey: an empty filter silently dropping
|
|
387
|
+
// would widen a previously scoped pull to the whole pool.
|
|
388
|
+
if (statuses.length === 0) {
|
|
389
|
+
throw new SyncPassError(
|
|
390
|
+
`config.status must name at least one task status, got ${JSON.stringify(config.status)}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
filters.status = statuses;
|
|
394
|
+
}
|
|
395
|
+
// Scoping config fails CLOSED: silently dropping a malformed filter would
|
|
396
|
+
// widen the pull to the whole task pool.
|
|
397
|
+
if (config.agentId !== undefined) {
|
|
398
|
+
if (typeof config.agentId !== "string" || config.agentId.trim().length === 0) {
|
|
399
|
+
throw new SyncPassError(
|
|
400
|
+
`config.agentId must be a non-empty string, got ${JSON.stringify(config.agentId)}`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
filters.agentId = config.agentId.trim();
|
|
404
|
+
}
|
|
405
|
+
if (config.tags !== undefined) {
|
|
406
|
+
const tags = commaList(config.tags);
|
|
407
|
+
if (tags.length === 0) {
|
|
408
|
+
throw new SyncPassError(
|
|
409
|
+
`config.tags must name at least one tag, got ${JSON.stringify(config.tags)}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
filters.tags = tags;
|
|
413
|
+
}
|
|
414
|
+
if (config.assetKey !== undefined) {
|
|
415
|
+
if (typeof config.assetKey !== "string" || config.assetKey.trim().length === 0) {
|
|
416
|
+
throw new SyncPassError(
|
|
417
|
+
`config.assetKey must be a non-empty string, got ${JSON.stringify(config.assetKey)}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
filters.keyPrefix = config.assetKey.trim();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Mirror the get-tasks surface: a user principal is hard-scoped to tasks it
|
|
424
|
+
// requested. A scoped window can never confirm absence outside that scope,
|
|
425
|
+
// so the pull always reports incomplete and the stale sweep stays off.
|
|
426
|
+
const scopedUserId = invokedBy?.startsWith("user:") ? invokedBy.slice("user:".length) : undefined;
|
|
427
|
+
if (scopedUserId !== undefined) {
|
|
428
|
+
filters.requestedByUserId = scopedUserId;
|
|
429
|
+
warn(warnings, "pull scoped to tasks requested by the invoking user");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const records = getAllTasks(filters).map(taskRecord);
|
|
433
|
+
// A full page means the window may have cut records off.
|
|
434
|
+
return { records, complete: scopedUserId === undefined && records.length < limit, warnings };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Projection
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function getByDottedPath(fields: Record<string, unknown>, path: string): unknown {
|
|
442
|
+
let value: unknown = fields;
|
|
443
|
+
for (const segment of path.split(".")) {
|
|
444
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
445
|
+
value = (value as Record<string, unknown>)[segment];
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function slugify(value: string): string {
|
|
451
|
+
return value
|
|
452
|
+
.toLowerCase()
|
|
453
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
454
|
+
.replace(/^-+|-+$/g, "");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function applyTransform(value: unknown, transform: SourceTransform | undefined): unknown {
|
|
458
|
+
if (value === undefined || value === null) return null;
|
|
459
|
+
switch (transform) {
|
|
460
|
+
case undefined:
|
|
461
|
+
return value;
|
|
462
|
+
case "lower":
|
|
463
|
+
return String(value).toLowerCase();
|
|
464
|
+
case "upper":
|
|
465
|
+
return String(value).toUpperCase();
|
|
466
|
+
case "slug":
|
|
467
|
+
return slugify(String(value));
|
|
468
|
+
case "cents": {
|
|
469
|
+
const numeric = Number(value);
|
|
470
|
+
if (!Number.isFinite(numeric)) throw new TransformError(`"${String(value)}" is not a number`);
|
|
471
|
+
return Math.round(numeric * 100);
|
|
472
|
+
}
|
|
473
|
+
case "date-parse": {
|
|
474
|
+
const date = new Date(typeof value === "number" ? value : String(value));
|
|
475
|
+
if (Number.isNaN(date.getTime()))
|
|
476
|
+
throw new TransformError(`"${String(value)}" is not a date`);
|
|
477
|
+
return date.toISOString();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Mirrors the row-store's per-kind value check. Projections are pre-validated
|
|
484
|
+
* here so one bad field nulls one column instead of throwing mid-reconcile and
|
|
485
|
+
* leaving the pass half-applied.
|
|
486
|
+
*/
|
|
487
|
+
function validForKind(column: ColumnDef, value: unknown): boolean {
|
|
488
|
+
if (column.kind === "string") return typeof value === "string";
|
|
489
|
+
if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
|
|
490
|
+
if (column.kind === "boolean") return typeof value === "boolean";
|
|
491
|
+
if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
|
|
492
|
+
return typeof value === "string" && Boolean(column.enum?.includes(value));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function projectRecord(args: {
|
|
496
|
+
record: SourceRecord;
|
|
497
|
+
bindings: Array<[string, ColumnDef]>;
|
|
498
|
+
joinKey: string;
|
|
499
|
+
warnings: string[];
|
|
500
|
+
}): Record<string, unknown> {
|
|
501
|
+
const values: Record<string, unknown> = { [args.joinKey]: args.record.key };
|
|
502
|
+
for (const [name, column] of args.bindings) {
|
|
503
|
+
const binding = column.source;
|
|
504
|
+
if (!binding) continue;
|
|
505
|
+
const raw = getByDottedPath(args.record.fields, binding.field);
|
|
506
|
+
let value: unknown;
|
|
507
|
+
try {
|
|
508
|
+
value = applyTransform(raw, binding.transform);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
warn(
|
|
511
|
+
args.warnings,
|
|
512
|
+
`column "${name}": transform "${binding.transform}" failed — ${error instanceof Error ? error.message : String(error)}`,
|
|
513
|
+
);
|
|
514
|
+
value = null;
|
|
515
|
+
}
|
|
516
|
+
if (value !== null && !validForKind(column, value)) {
|
|
517
|
+
warn(
|
|
518
|
+
args.warnings,
|
|
519
|
+
`column "${name}": field "${binding.field}" produced a value that is not a valid ${column.kind}`,
|
|
520
|
+
);
|
|
521
|
+
value = null;
|
|
522
|
+
}
|
|
523
|
+
values[name] = value;
|
|
524
|
+
}
|
|
525
|
+
return values;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** A missing column and an explicit null are the same absence of a value. */
|
|
529
|
+
function sameValue(existing: unknown, projected: unknown): boolean {
|
|
530
|
+
return Object.is(existing ?? null, projected ?? null);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Reconcile
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
type PairDefinition = { model: ModelDef; source: SourceDef };
|
|
538
|
+
|
|
539
|
+
function resolvePair(appId: string, model: string, source: string): PairDefinition | null {
|
|
540
|
+
const app = getApp(appId);
|
|
541
|
+
if (!app || appDefinitionNeedsRepair(app)) return null;
|
|
542
|
+
const modelDef = app.definition.models[model];
|
|
543
|
+
const sourceDef = modelDef?.sources?.[source];
|
|
544
|
+
return modelDef && sourceDef ? { model: modelDef, source: sourceDef } : null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Everything a pass's projection depends on: the full source definition plus
|
|
549
|
+
* the shape of every column bound to it. Snapshotted before the unlocked pull
|
|
550
|
+
* and compared under the lock — ANY drift (script, args, connection, config,
|
|
551
|
+
* join key, bindings) aborts the pass rather than projecting a stale payload
|
|
552
|
+
* through fresh rules. Serialization order is stable because both reads parse
|
|
553
|
+
* the same stored definition; a concurrent definition write that merely
|
|
554
|
+
* reorders keys aborts too, which is the safe direction.
|
|
555
|
+
*/
|
|
556
|
+
function pairFingerprint(model: ModelDef, sourceName: string, source: SourceDef): string {
|
|
557
|
+
const bindings = Object.entries(model.columns)
|
|
558
|
+
.filter(([, column]) => column.source?.of === sourceName)
|
|
559
|
+
.map(([name, column]) => ({
|
|
560
|
+
name,
|
|
561
|
+
kind: column.kind,
|
|
562
|
+
enum: column.enum ?? null,
|
|
563
|
+
required: column.required === true,
|
|
564
|
+
hidden: column.hidden === true,
|
|
565
|
+
field: column.source?.field,
|
|
566
|
+
transform: column.source?.transform ?? null,
|
|
567
|
+
}));
|
|
568
|
+
return JSON.stringify({ source, bindings });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
type ReconcileCounts = {
|
|
572
|
+
created: number;
|
|
573
|
+
updated: number;
|
|
574
|
+
refreshed: number;
|
|
575
|
+
unchanged: number;
|
|
576
|
+
markedStale: number;
|
|
577
|
+
staleSweepSkipped?: boolean;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
function reconcile(args: {
|
|
581
|
+
appId: string;
|
|
582
|
+
model: string;
|
|
583
|
+
sourceName: string;
|
|
584
|
+
fingerprint: string;
|
|
585
|
+
joinKey: string;
|
|
586
|
+
pull: PullResult;
|
|
587
|
+
warnings: string[];
|
|
588
|
+
/**
|
|
589
|
+
* Mutated write-by-write so a mid-pass failure still reports the row churn
|
|
590
|
+
* that actually committed — the writes are independent KV upserts, not a
|
|
591
|
+
* transaction, and a thrown error must not zero them out.
|
|
592
|
+
*/
|
|
593
|
+
counts: ReconcileCounts;
|
|
594
|
+
}): Promise<void> {
|
|
595
|
+
const { appId, model, sourceName, joinKey, pull, warnings, counts } = args;
|
|
596
|
+
return withMutationLock(appId, model, () => {
|
|
597
|
+
// Re-read under the lock: the pull ran unlocked, so the definition it was
|
|
598
|
+
// planned against may be gone. Anything that moves the identity or the
|
|
599
|
+
// projection rules of this pair aborts before the first write.
|
|
600
|
+
const fresh = resolvePair(appId, model, sourceName);
|
|
601
|
+
if (!fresh) {
|
|
602
|
+
throw new SyncPassError(
|
|
603
|
+
`model "${model}" no longer declares source "${sourceName}"; pass aborted with no writes`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (pairFingerprint(fresh.model, sourceName, fresh.source) !== args.fingerprint) {
|
|
607
|
+
throw new SyncPassError(
|
|
608
|
+
`source "${sourceName}" changed while the pull was running; pass aborted with no writes`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const modelDef = fresh.model;
|
|
612
|
+
// Hidden columns are rejected by the row-store on every write path, so a
|
|
613
|
+
// hidden binding must not be projected.
|
|
614
|
+
const bindings = Object.entries(modelDef.columns).filter(
|
|
615
|
+
([, column]) => column.source?.of === sourceName && column.hidden !== true,
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const mine = new Map<string, AppRow>();
|
|
619
|
+
const unkeyed: AppRow[] = [];
|
|
620
|
+
// Reconcile holds the mutation lock, so the unbounded pager is safe. The
|
|
621
|
+
// plain listAppRows cap (100k) would hide rows past it: pulled keys would
|
|
622
|
+
// duplicate and the hidden rows would never be swept stale.
|
|
623
|
+
for (const row of listAllAppRowsForMigrationUnlocked(appId, model)) {
|
|
624
|
+
if (row.source !== sourceName) continue;
|
|
625
|
+
const key = row[joinKey];
|
|
626
|
+
if (typeof key === "string") mine.set(key, row);
|
|
627
|
+
else unkeyed.push(row);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const now = new Date().toISOString();
|
|
631
|
+
const actor = `sync:${sourceName}`;
|
|
632
|
+
const seen = new Set<string>();
|
|
633
|
+
|
|
634
|
+
for (const record of pull.records) {
|
|
635
|
+
if (seen.has(record.key)) {
|
|
636
|
+
warn(warnings, `source returned duplicate key "${record.key}"; the last record wins`);
|
|
637
|
+
}
|
|
638
|
+
seen.add(record.key);
|
|
639
|
+
const values = projectRecord({ record, bindings, joinKey, warnings });
|
|
640
|
+
const envelope = { source: sourceName, syncedAt: now, stale: false };
|
|
641
|
+
const existing = mine.get(record.key);
|
|
642
|
+
if (!existing) {
|
|
643
|
+
// No adoption: a row with no source of its own is not ours to take.
|
|
644
|
+
const created = createAppRowUnlocked(appId, model, modelDef, values, {
|
|
645
|
+
allowSourceManaged: true,
|
|
646
|
+
envelope,
|
|
647
|
+
actor,
|
|
648
|
+
});
|
|
649
|
+
mine.set(record.key, created);
|
|
650
|
+
counts.created += 1;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const differs = Object.entries(values).some(
|
|
654
|
+
([name, value]) => !sameValue(existing[name], value),
|
|
655
|
+
);
|
|
656
|
+
const updated = patchAppRowUnlocked(appId, model, modelDef, existing.id, values, {
|
|
657
|
+
allowSourceManaged: true,
|
|
658
|
+
envelope,
|
|
659
|
+
actor,
|
|
660
|
+
// Freshness must be meaningful: syncedAt advances on every confirmed
|
|
661
|
+
// row, updatedAt only when projected data actually moved.
|
|
662
|
+
...(differs ? {} : { skipUpdatedAt: true }),
|
|
663
|
+
});
|
|
664
|
+
if (!updated) {
|
|
665
|
+
warn(warnings, `row "${existing.id}" vanished before it could be updated`);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
mine.set(record.key, updated);
|
|
669
|
+
if (differs) counts.updated += 1;
|
|
670
|
+
else counts.refreshed += 1;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const unseen = [...mine.entries()]
|
|
674
|
+
.filter(([key]) => !seen.has(key))
|
|
675
|
+
.map(([, row]) => row)
|
|
676
|
+
.concat(unkeyed);
|
|
677
|
+
if (!pull.complete) {
|
|
678
|
+
counts.staleSweepSkipped = true;
|
|
679
|
+
counts.unchanged += unseen.length;
|
|
680
|
+
warn(warnings, "pull reported an incomplete window; stale sweep skipped");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
for (const row of unseen) {
|
|
684
|
+
if (row.stale === true) {
|
|
685
|
+
counts.unchanged += 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
patchAppRowUnlocked(
|
|
689
|
+
appId,
|
|
690
|
+
model,
|
|
691
|
+
modelDef,
|
|
692
|
+
row.id,
|
|
693
|
+
{},
|
|
694
|
+
{
|
|
695
|
+
allowSourceManaged: true,
|
|
696
|
+
skipUpdatedAt: true,
|
|
697
|
+
// syncedAt records last confirmed presence, so it stays frozen here.
|
|
698
|
+
envelope: {
|
|
699
|
+
source: sourceName,
|
|
700
|
+
syncedAt: typeof row.syncedAt === "string" ? row.syncedAt : now,
|
|
701
|
+
stale: true,
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
counts.markedStale += 1;
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
// Pass orchestration
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
/** In-process single-flight guard, keyed `<appId>:<model>:<source>`. */
|
|
715
|
+
const inFlight = new Map<string, Promise<SyncPassResult>>();
|
|
716
|
+
|
|
717
|
+
function passBase(args: {
|
|
718
|
+
model: string;
|
|
719
|
+
source: string;
|
|
720
|
+
connector: SourceDef["connector"];
|
|
721
|
+
invokedBy?: string;
|
|
722
|
+
}): SyncPassResult {
|
|
723
|
+
return {
|
|
724
|
+
model: args.model,
|
|
725
|
+
source: args.source,
|
|
726
|
+
connector: args.connector,
|
|
727
|
+
pulled: 0,
|
|
728
|
+
created: 0,
|
|
729
|
+
updated: 0,
|
|
730
|
+
refreshed: 0,
|
|
731
|
+
unchanged: 0,
|
|
732
|
+
markedStale: 0,
|
|
733
|
+
warnings: [],
|
|
734
|
+
durationMs: 0,
|
|
735
|
+
...(args.invokedBy === undefined ? {} : { invokedBy: args.invokedBy }),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function executePass(args: {
|
|
740
|
+
appId: string;
|
|
741
|
+
model: string;
|
|
742
|
+
sourceName: string;
|
|
743
|
+
source: SourceDef;
|
|
744
|
+
invokedBy?: string;
|
|
745
|
+
}): Promise<SyncPassResult> {
|
|
746
|
+
const startedMs = Date.now();
|
|
747
|
+
const lastStartedAt = new Date(startedMs).toISOString();
|
|
748
|
+
const warnings: string[] = [];
|
|
749
|
+
const base = passBase({
|
|
750
|
+
model: args.model,
|
|
751
|
+
source: args.sourceName,
|
|
752
|
+
connector: args.source.connector,
|
|
753
|
+
invokedBy: args.invokedBy,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Set once the pair resolves. finish() skips the status write when the live
|
|
757
|
+
// definition no longer matches this pass's snapshot: an obsolete pass must
|
|
758
|
+
// not resurrect status a migration just invalidated, nor seed an orphan key
|
|
759
|
+
// for a pair that changed or vanished while it was in flight.
|
|
760
|
+
let plannedFingerprint: string | null = null;
|
|
761
|
+
|
|
762
|
+
const finish = (result: SyncPassResult): SyncPassResult => {
|
|
763
|
+
const scrubbed = scrubObject({ ...result, warnings, durationMs: Date.now() - startedMs });
|
|
764
|
+
const current = resolvePair(args.appId, args.model, args.sourceName);
|
|
765
|
+
const stillCurrent =
|
|
766
|
+
plannedFingerprint !== null &&
|
|
767
|
+
current !== null &&
|
|
768
|
+
pairFingerprint(current.model, args.sourceName, current.source) === plannedFingerprint;
|
|
769
|
+
if (stillCurrent) writeSyncStatus(args.appId, scrubbed, lastStartedAt);
|
|
770
|
+
return scrubbed;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// Reconcile mutates this accumulator write-by-write, so the error path below
|
|
774
|
+
// reports the churn that actually committed instead of the zero-count base.
|
|
775
|
+
const counts: ReconcileCounts = {
|
|
776
|
+
created: 0,
|
|
777
|
+
updated: 0,
|
|
778
|
+
refreshed: 0,
|
|
779
|
+
unchanged: 0,
|
|
780
|
+
markedStale: 0,
|
|
781
|
+
};
|
|
782
|
+
let pulled = 0;
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
// Snapshot the pair's projection rules before the unlocked pull; reconcile
|
|
786
|
+
// compares under the lock and aborts on any drift.
|
|
787
|
+
const planned = resolvePair(args.appId, args.model, args.sourceName);
|
|
788
|
+
if (!planned) {
|
|
789
|
+
throw new SyncPassError(
|
|
790
|
+
`model "${args.model}" no longer declares source "${args.sourceName}"; pass aborted with no writes`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
// Pull from the SAME snapshot the fingerprint was computed from:
|
|
794
|
+
// args.source was captured at pair-selection time and may already be
|
|
795
|
+
// stale (an earlier pass's await lets the definition move); pulling it
|
|
796
|
+
// while fingerprinting the fresh resolve would let drifted data commit.
|
|
797
|
+
const source = planned.source;
|
|
798
|
+
const fingerprint = pairFingerprint(planned.model, args.sourceName, source);
|
|
799
|
+
plannedFingerprint = fingerprint;
|
|
800
|
+
|
|
801
|
+
// Pull OUTSIDE the lock; reconcile inside it. Pulled values persist into
|
|
802
|
+
// rows any app.use principal can later read, so secrets are redacted here
|
|
803
|
+
// at the persistence boundary — the finish() scrub only covers the pass
|
|
804
|
+
// summary, never pull.records.
|
|
805
|
+
const pull = scrubObject(
|
|
806
|
+
source.connector === "script"
|
|
807
|
+
? await pullFromScript({
|
|
808
|
+
appId: args.appId,
|
|
809
|
+
model: args.model,
|
|
810
|
+
sourceName: args.sourceName,
|
|
811
|
+
source,
|
|
812
|
+
})
|
|
813
|
+
: pullFromSwarmTasks(source, args.invokedBy),
|
|
814
|
+
);
|
|
815
|
+
pulled = pull.records.length;
|
|
816
|
+
for (const warning of pull.warnings) warn(warnings, warning);
|
|
817
|
+
await reconcile({
|
|
818
|
+
appId: args.appId,
|
|
819
|
+
model: args.model,
|
|
820
|
+
sourceName: args.sourceName,
|
|
821
|
+
fingerprint,
|
|
822
|
+
joinKey: source.joinKey,
|
|
823
|
+
pull,
|
|
824
|
+
warnings,
|
|
825
|
+
counts,
|
|
826
|
+
});
|
|
827
|
+
return finish({ ...base, ...counts, pulled });
|
|
828
|
+
} catch (error) {
|
|
829
|
+
const message =
|
|
830
|
+
error instanceof SyncPassError
|
|
831
|
+
? error.message
|
|
832
|
+
: error instanceof Error
|
|
833
|
+
? `${error.name}: ${error.message}`
|
|
834
|
+
: String(error);
|
|
835
|
+
return finish({ ...base, ...counts, pulled, error: message });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function runPass(args: {
|
|
840
|
+
appId: string;
|
|
841
|
+
model: string;
|
|
842
|
+
sourceName: string;
|
|
843
|
+
source: SourceDef;
|
|
844
|
+
invokedBy?: string;
|
|
845
|
+
}): Promise<SyncPassResult> {
|
|
846
|
+
const key = `${args.appId}:${args.model}:${args.sourceName}`;
|
|
847
|
+
if (inFlight.has(key)) {
|
|
848
|
+
// A pass for this pair is already running: do not pull again.
|
|
849
|
+
return scrubObject({
|
|
850
|
+
...passBase({
|
|
851
|
+
model: args.model,
|
|
852
|
+
source: args.sourceName,
|
|
853
|
+
connector: args.source.connector,
|
|
854
|
+
invokedBy: args.invokedBy,
|
|
855
|
+
}),
|
|
856
|
+
warnings: ["a sync pass for this pair is already running; this trigger did not pull"],
|
|
857
|
+
skipped: true as const,
|
|
858
|
+
alreadyRunning: true as const,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const pass = executePass(args);
|
|
862
|
+
inFlight.set(key, pass);
|
|
863
|
+
try {
|
|
864
|
+
return await pass;
|
|
865
|
+
} finally {
|
|
866
|
+
if (inFlight.get(key) === pass) inFlight.delete(key);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function noPairIssue(input: { model?: string; source?: string }): AppValidationIssue {
|
|
871
|
+
if (input.source !== undefined) {
|
|
872
|
+
return {
|
|
873
|
+
path: "source",
|
|
874
|
+
message:
|
|
875
|
+
input.model !== undefined
|
|
876
|
+
? `unknown source "${input.source}" on model "${input.model}"`
|
|
877
|
+
: `unknown source "${input.source}" — no model declares it`,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
if (input.model !== undefined) {
|
|
881
|
+
return { path: "model", message: `model "${input.model}" declares no sources` };
|
|
882
|
+
}
|
|
883
|
+
return { path: "appId", message: "no model declares a source to sync" };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Run every `(model x source)` pair the input selects, sequentially. Each pass
|
|
888
|
+
* pulls outside the per-(app, model) mutation lock and reconciles inside it.
|
|
889
|
+
*/
|
|
890
|
+
export async function runAppSync(input: {
|
|
891
|
+
appId: string;
|
|
892
|
+
model?: string;
|
|
893
|
+
source?: string;
|
|
894
|
+
invokedBy?: string;
|
|
895
|
+
}): Promise<AppSyncResult> {
|
|
896
|
+
const app = getApp(input.appId);
|
|
897
|
+
if (!app) {
|
|
898
|
+
return {
|
|
899
|
+
ok: false,
|
|
900
|
+
passes: [],
|
|
901
|
+
issues: [{ path: "appId", message: `app "${input.appId}" not found` }],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
if (appDefinitionNeedsRepair(app)) {
|
|
905
|
+
return {
|
|
906
|
+
ok: false,
|
|
907
|
+
passes: [],
|
|
908
|
+
issues: [{ path: "definition", message: "app definition needs repair before it can sync" }],
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
const models = app.definition.models;
|
|
912
|
+
if (input.model !== undefined && !Object.hasOwn(models, input.model)) {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
passes: [],
|
|
916
|
+
issues: [{ path: "model", message: `unknown model "${input.model}"` }],
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const pairs = (input.model !== undefined ? [input.model] : Object.keys(models)).flatMap((model) =>
|
|
921
|
+
Object.entries(models[model]?.sources ?? {})
|
|
922
|
+
.filter(([sourceName]) => input.source === undefined || sourceName === input.source)
|
|
923
|
+
.map(([sourceName, source]) => ({ model, sourceName, source })),
|
|
924
|
+
);
|
|
925
|
+
if (pairs.length === 0) {
|
|
926
|
+
return { ok: false, passes: [], issues: [noPairIssue(input)] };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const passes: SyncPassResult[] = [];
|
|
930
|
+
for (const pair of pairs) {
|
|
931
|
+
passes.push(await runPass({ appId: app.id, ...pair, invokedBy: input.invokedBy }));
|
|
932
|
+
}
|
|
933
|
+
return { ok: passes.every((pass) => pass.error === undefined), passes };
|
|
934
|
+
}
|