@mastra/factory 0.2.0 → 0.2.1-alpha.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/CHANGELOG.md +24 -0
- package/dist/capabilities/intake.d.ts +45 -0
- package/dist/capabilities/intake.d.ts.map +1 -1
- package/dist/factory.js +920 -549
- package/dist/factory.js.map +1 -1
- package/dist/index.js +920 -549
- package/dist/index.js.map +1 -1
- package/dist/integrations/github/integration.d.ts.map +1 -1
- package/dist/integrations/github/integration.js +126 -11
- package/dist/integrations/github/integration.js.map +1 -1
- package/dist/integrations/github/project-lock.d.ts +24 -2
- package/dist/integrations/github/project-lock.d.ts.map +1 -1
- package/dist/integrations/github/project-lock.js +43 -10
- package/dist/integrations/github/project-lock.js.map +1 -1
- package/dist/integrations/github/routes.js +41 -10
- package/dist/integrations/github/routes.js.map +1 -1
- package/dist/integrations/linear/agent-tools.js.map +1 -1
- package/dist/integrations/linear/integration.d.ts.map +1 -1
- package/dist/integrations/linear/integration.js +60 -1
- package/dist/integrations/linear/integration.js.map +1 -1
- package/dist/integrations/linear/routes.js.map +1 -1
- package/dist/integrations/platform/github/event-worker.d.ts.map +1 -1
- package/dist/integrations/platform/github/event-worker.js +7 -1
- package/dist/integrations/platform/github/event-worker.js.map +1 -1
- package/dist/integrations/platform/github/integration.d.ts.map +1 -1
- package/dist/integrations/platform/github/integration.js +104 -12
- package/dist/integrations/platform/github/integration.js.map +1 -1
- package/dist/integrations/platform/linear/integration.d.ts.map +1 -1
- package/dist/integrations/platform/linear/integration.js +91 -0
- package/dist/integrations/platform/linear/integration.js.map +1 -1
- package/dist/routes/fs.d.ts +16 -0
- package/dist/routes/fs.d.ts.map +1 -1
- package/dist/routes/fs.js +103 -3
- package/dist/routes/fs.js.map +1 -1
- package/dist/routes/oauth.js +564 -478
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +1 -0
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/surface.d.ts.map +1 -1
- package/dist/routes/surface.js +717 -526
- package/dist/routes/surface.js.map +1 -1
- package/dist/storage/domains/source-control/base.d.ts +0 -1
- package/dist/storage/domains/source-control/base.d.ts.map +1 -1
- package/dist/storage/domains/source-control/base.js +6 -7
- package/dist/storage/domains/source-control/base.js.map +1 -1
- package/package.json +4 -4
|
@@ -27,6 +27,20 @@ export interface LockClient {
|
|
|
27
27
|
query(sql: string, params?: unknown[]): Promise<unknown>;
|
|
28
28
|
release(): void;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Default hard cap on how long a critical section may run inside the project
|
|
32
|
+
* lock. Prevents an untimed outbound call (sandbox HTTP, GitHub App API, git
|
|
33
|
+
* push, etc.) from pinning both the advisory lock and the pg pool connection
|
|
34
|
+
* indefinitely — the failure mode behind the 2025-07-23 shipyard 30s plateau
|
|
35
|
+
* incident. Callers can override per call via `timeoutMs`.
|
|
36
|
+
*/
|
|
37
|
+
export declare const DEFAULT_PROJECT_LOCK_TIMEOUT_MS = 60000;
|
|
38
|
+
/** Thrown when the critical section under `withProjectLock` exceeds the timeout. */
|
|
39
|
+
export declare class ProjectLockTimeoutError extends Error {
|
|
40
|
+
readonly key: string;
|
|
41
|
+
readonly timeoutMs: number;
|
|
42
|
+
constructor(key: string, timeoutMs: number);
|
|
43
|
+
}
|
|
30
44
|
/**
|
|
31
45
|
* True when the cross-replica lock layer should be used: not force-disabled
|
|
32
46
|
* via env, and the factory storage backend exposes the
|
|
@@ -52,9 +66,16 @@ export declare function withProjectLock<T>(options: {
|
|
|
52
66
|
key: string;
|
|
53
67
|
/** Factory storage backend supplying the `withDistributedLock` capability, when available. */
|
|
54
68
|
storage?: FactoryStorage;
|
|
55
|
-
fn: () => Promise<T>;
|
|
69
|
+
fn: (signal: AbortSignal) => Promise<T>;
|
|
56
70
|
/** Test seam: fake pg pool standing in for the distributed layer. */
|
|
57
71
|
pool?: LockPool;
|
|
72
|
+
/**
|
|
73
|
+
* Hard cap on the critical section. Defaults to
|
|
74
|
+
* {@link DEFAULT_PROJECT_LOCK_TIMEOUT_MS}. On timeout `fn`'s abort signal
|
|
75
|
+
* fires and the outer lock throws `ProjectLockTimeoutError`, releasing the
|
|
76
|
+
* advisory lock + pool connection.
|
|
77
|
+
*/
|
|
78
|
+
timeoutMs?: number;
|
|
58
79
|
}): Promise<T>;
|
|
59
80
|
/**
|
|
60
81
|
* Acquire only the cross-replica lock for `key` and run `fn` under it. This is
|
|
@@ -70,8 +91,9 @@ export declare function withProjectLock<T>(options: {
|
|
|
70
91
|
export declare function withDbAdvisoryLock<T>(options: {
|
|
71
92
|
key: string;
|
|
72
93
|
storage?: FactoryStorage;
|
|
73
|
-
fn: () => Promise<T>;
|
|
94
|
+
fn: (signal: AbortSignal) => Promise<T>;
|
|
74
95
|
pool?: LockPool;
|
|
96
|
+
timeoutMs?: number;
|
|
75
97
|
}): Promise<T>;
|
|
76
98
|
/** For tests: clear the in-process lock chains. */
|
|
77
99
|
export declare function __resetProjectLocksForTests(): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"project-lock.d.ts","sourceRoot":"","sources":["../../../src/integrations/github/project-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,WAAW,QAAQ;IACvB,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC;AACD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,OAAO,IAAI,IAAI,CAAC;CACjB;AAID;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,cAAc,GAAG,SAAS,GAAG,OAAO,CAGrF;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,8FAA8F;IAC9F,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"project-lock.d.ts","sourceRoot":"","sources":["../../../src/integrations/github/project-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,WAAW,QAAQ;IACvB,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC;AACD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,OAAO,IAAI,IAAI,CAAC;CACjB;AAID;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,QAAS,CAAC;AAEtD,oFAAoF;AACpF,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBACf,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;CAM3C;AA+BD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,cAAc,GAAG,SAAS,GAAG,OAAO,CAGrF;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,8FAA8F;IAC9F,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,EAAE,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,qEAAqE;IACrE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,CAAC,CAAC,CAmBb;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EAAE,OAAO,EAAE;IACnD,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,EAAE,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,CAAC,CAAC,CAgBb;AAqCD,mDAAmD;AACnD,wBAAgB,2BAA2B,IAAI,IAAI,CAElD"}
|
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
// src/integrations/github/project-lock.ts
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
var inProcessLocks = /* @__PURE__ */ new Map();
|
|
4
|
+
var DEFAULT_PROJECT_LOCK_TIMEOUT_MS = 6e4;
|
|
5
|
+
var ProjectLockTimeoutError = class extends Error {
|
|
6
|
+
key;
|
|
7
|
+
timeoutMs;
|
|
8
|
+
constructor(key, timeoutMs) {
|
|
9
|
+
super(`Project lock critical section for "${key}" exceeded ${timeoutMs}ms`);
|
|
10
|
+
this.name = "ProjectLockTimeoutError";
|
|
11
|
+
this.key = key;
|
|
12
|
+
this.timeoutMs = timeoutMs;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
async function runWithTimeout(key, timeoutMs, fn) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
18
|
+
const abortError = new Promise((_, reject) => {
|
|
19
|
+
controller.signal.addEventListener("abort", () => reject(new ProjectLockTimeoutError(key, timeoutMs)), {
|
|
20
|
+
once: true
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
const work = fn(controller.signal);
|
|
24
|
+
work.catch(() => {
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
return await Promise.race([work, abortError]);
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
4
32
|
function isDistributedLockEnabled(storage) {
|
|
5
33
|
if (process.env.MASTRACODE_DISTRIBUTED_LOCK === "0") return false;
|
|
6
34
|
return typeof storage?.withDistributedLock === "function";
|
|
@@ -12,9 +40,9 @@ function hashKey(key) {
|
|
|
12
40
|
return [a, b];
|
|
13
41
|
}
|
|
14
42
|
function withProjectLock(options) {
|
|
15
|
-
const { key, storage, fn, pool } = options;
|
|
43
|
+
const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;
|
|
16
44
|
const prev = inProcessLocks.get(key) ?? Promise.resolve();
|
|
17
|
-
const run = () => withDbAdvisoryLock({ key, storage, fn, pool });
|
|
45
|
+
const run = () => withDbAdvisoryLock({ key, storage, fn, pool, timeoutMs });
|
|
18
46
|
const next = prev.then(run, run);
|
|
19
47
|
const tail = next.then(
|
|
20
48
|
() => void 0,
|
|
@@ -29,28 +57,31 @@ function withProjectLock(options) {
|
|
|
29
57
|
return next;
|
|
30
58
|
}
|
|
31
59
|
async function withDbAdvisoryLock(options) {
|
|
32
|
-
const { key, storage, fn, pool } = options;
|
|
60
|
+
const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;
|
|
33
61
|
if (process.env.MASTRACODE_DISTRIBUTED_LOCK === "0") {
|
|
34
|
-
return fn
|
|
62
|
+
return runWithTimeout(key, timeoutMs, fn);
|
|
35
63
|
}
|
|
36
|
-
if (pool) return advisoryLockOver(pool, key, fn);
|
|
64
|
+
if (pool) return advisoryLockOver(pool, key, timeoutMs, fn);
|
|
37
65
|
if (typeof storage?.withDistributedLock !== "function") {
|
|
38
|
-
return fn
|
|
66
|
+
return runWithTimeout(key, timeoutMs, fn);
|
|
39
67
|
}
|
|
40
|
-
return storage.withDistributedLock(key, fn);
|
|
68
|
+
return storage.withDistributedLock(key, () => runWithTimeout(key, timeoutMs, fn));
|
|
41
69
|
}
|
|
42
|
-
async function advisoryLockOver(pool, key, fn) {
|
|
70
|
+
async function advisoryLockOver(pool, key, timeoutMs, fn) {
|
|
43
71
|
const [k1, k2] = hashKey(key);
|
|
44
72
|
const client = await pool.connect();
|
|
45
73
|
try {
|
|
46
74
|
await client.query("BEGIN");
|
|
47
75
|
await client.query("SELECT pg_advisory_xact_lock($1, $2)", [k1, k2]);
|
|
48
76
|
try {
|
|
49
|
-
const result = await fn
|
|
77
|
+
const result = await runWithTimeout(key, timeoutMs, fn);
|
|
50
78
|
await client.query("COMMIT");
|
|
51
79
|
return result;
|
|
52
80
|
} catch (err) {
|
|
53
|
-
|
|
81
|
+
try {
|
|
82
|
+
await client.query("ROLLBACK");
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
54
85
|
throw err;
|
|
55
86
|
}
|
|
56
87
|
} finally {
|
|
@@ -61,6 +92,8 @@ function __resetProjectLocksForTests() {
|
|
|
61
92
|
inProcessLocks.clear();
|
|
62
93
|
}
|
|
63
94
|
export {
|
|
95
|
+
DEFAULT_PROJECT_LOCK_TIMEOUT_MS,
|
|
96
|
+
ProjectLockTimeoutError,
|
|
64
97
|
__resetProjectLocksForTests,
|
|
65
98
|
hashKey,
|
|
66
99
|
isDistributedLockEnabled,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/integrations/github/project-lock.ts"],"sourcesContent":["/**\n * Per-(project, user) lock that serializes the worktree/commit/push/PR flows.\n *\n * The push/PR flows temporarily rewrite the sandbox git remote to a tokenized\n * URL and scrub it again in a `finally`; two concurrent operations on the same\n * `(project, user)` sandbox could interleave those rewrites and leak a tokenized\n * remote. Serializing per `(project, user)` removes that race.\n *\n * There are two layers:\n * 1. An **in-process** promise-chain mutex keyed by the lock key, so repeated\n * same-replica callers stay cheap and never touch the database for ordering.\n * 2. The factory storage backend's **`withDistributedLock` capability** (pg:\n * transaction-scoped advisory locks) so that *different replicas*\n * operating on the same key also serialize. Backends without the\n * capability (libsql: local single-writer) fall back to the in-process\n * mutex alone — correct for single-replica deployments.\n *\n * Set `MASTRACODE_DISTRIBUTED_LOCK=0` to force-disable the distributed layer\n * (local dev, single replica) and fall back to the pure in-process mutex.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { FactoryStorage } from '@mastra/core/storage';\n\n/** Minimal pg pool surface used by the distributed lock (for testability). */\nexport interface LockPool {\n connect(): Promise<LockClient>;\n}\nexport interface LockClient {\n query(sql: string, params?: unknown[]): Promise<unknown>;\n release(): void;\n}\n\nconst inProcessLocks = new Map<string, Promise<unknown>>();\n\n/**\n * True when the cross-replica lock layer should be used: not force-disabled\n * via env, and the factory storage backend exposes the\n * `withDistributedLock` capability.\n */\nexport function isDistributedLockEnabled(storage: FactoryStorage | undefined): boolean {\n if (process.env.MASTRACODE_DISTRIBUTED_LOCK === '0') return false;\n return typeof storage?.withDistributedLock === 'function';\n}\n\n/**\n * Hash a lock key into the two signed 32-bit integers that the two-arg form of\n * `pg_advisory_xact_lock(int4, int4)` expects. Using two int4 args (rather than\n * one int8) keeps the key inside the GitHub-feature advisory-lock namespace and\n * avoids collisions with other single-int8 advisory locks.\n */\nexport function hashKey(key: string): [number, number] {\n const digest = createHash('sha256').update(key).digest();\n // Read two independent 32-bit halves as signed int4 values.\n const a = digest.readInt32BE(0);\n const b = digest.readInt32BE(4);\n return [a, b];\n}\n\n/**\n * Run `fn` while holding the lock for `key`. Same-replica callers serialize via\n * the in-process mutex; cross-replica callers additionally serialize via a\n * Postgres transaction-scoped advisory lock (unless disabled).\n *\n * The in-process chain swallows rejections so one failed operation does not\n * poison the lock for subsequent callers.\n */\nexport function withProjectLock<T>(options: {\n key: string;\n /** Factory storage backend supplying the `withDistributedLock` capability, when available. */\n storage?: FactoryStorage;\n fn: () => Promise<T>;\n /** Test seam: fake pg pool standing in for the distributed layer. */\n pool?: LockPool;\n}): Promise<T> {\n const { key, storage, fn, pool } = options;\n const prev = inProcessLocks.get(key) ?? Promise.resolve();\n const run = () => withDbAdvisoryLock({ key, storage, fn, pool });\n const next = prev.then(run, run);\n const tail = next.then(\n () => undefined,\n () => undefined,\n );\n inProcessLocks.set(key, tail);\n // Drop the entry once this operation settles, but only if no later caller has\n // chained onto it in the meantime — otherwise we'd evict a live waiter's tail.\n // This keeps the map from growing unbounded across many distinct project keys.\n void tail.then(() => {\n if (inProcessLocks.get(key) === tail) {\n inProcessLocks.delete(key);\n }\n });\n return next;\n}\n\n/**\n * Acquire only the cross-replica lock for `key` and run `fn` under it. This is\n * the distributed serialization layer; `withProjectLock` wraps it with an\n * in-process mutex for same-replica callers. Delegates to the factory storage\n * backend's `withDistributedLock` capability; backends without it (or a\n * force-disabled env) run `fn` directly — the in-process mutex still holds.\n *\n * `poolOverride` keeps the pg advisory-lock path directly testable with a fake\n * pool (each simulated replica has its own in-process state but shares one\n * database).\n */\nexport async function withDbAdvisoryLock<T>(options: {\n key: string;\n storage?: FactoryStorage;\n fn: () => Promise<T>;\n pool?: LockPool;\n}): Promise<T> {\n const { key, storage, fn, pool } = options;\n if (process.env.MASTRACODE_DISTRIBUTED_LOCK === '0') {\n return fn();\n }\n\n if (pool) return advisoryLockOver(pool, key, fn);\n\n if (typeof storage?.withDistributedLock !== 'function') {\n return fn();\n }\n return storage.withDistributedLock(key, fn);\n}\n\n/** The pg advisory-lock body, kept for the `poolOverride` test seam. */\nasync function advisoryLockOver<T>(pool: LockPool, key: string, fn: () => Promise<T>): Promise<T> {\n const [k1, k2] = hashKey(key);\n const client = await pool.connect();\n try {\n await client.query('BEGIN');\n // Blocks until no other transaction holds this advisory key. Auto-released\n // when the transaction ends below.\n await client.query('SELECT pg_advisory_xact_lock($1, $2)', [k1, k2]);\n try {\n const result = await fn();\n await client.query('COMMIT');\n return result;\n } catch (err) {\n await client.query('ROLLBACK');\n throw err;\n }\n } finally {\n client.release();\n }\n}\n\n/** For tests: clear the in-process lock chains. */\nexport function __resetProjectLocksForTests(): void {\n inProcessLocks.clear();\n}\n"],"mappings":";AAqBA,SAAS,kBAAkB;AAa3B,IAAM,iBAAiB,oBAAI,IAA8B;AAOlD,SAAS,yBAAyB,SAA8C;AACrF,MAAI,QAAQ,IAAI,gCAAgC,IAAK,QAAO;AAC5D,SAAO,OAAO,SAAS,wBAAwB;AACjD;AAQO,SAAS,QAAQ,KAA+B;AACrD,QAAM,SAAS,WAAW,QAAQ,EAAE,OAAO,GAAG,EAAE,OAAO;AAEvD,QAAM,IAAI,OAAO,YAAY,CAAC;AAC9B,QAAM,IAAI,OAAO,YAAY,CAAC;AAC9B,SAAO,CAAC,GAAG,CAAC;AACd;AAUO,SAAS,gBAAmB,SAOpB;AACb,QAAM,EAAE,KAAK,SAAS,IAAI,KAAK,IAAI;AACnC,QAAM,OAAO,eAAe,IAAI,GAAG,KAAK,QAAQ,QAAQ;AACxD,QAAM,MAAM,MAAM,mBAAmB,EAAE,KAAK,SAAS,IAAI,KAAK,CAAC;AAC/D,QAAM,OAAO,KAAK,KAAK,KAAK,GAAG;AAC/B,QAAM,OAAO,KAAK;AAAA,IAChB,MAAM;AAAA,IACN,MAAM;AAAA,EACR;AACA,iBAAe,IAAI,KAAK,IAAI;AAI5B,OAAK,KAAK,KAAK,MAAM;AACnB,QAAI,eAAe,IAAI,GAAG,MAAM,MAAM;AACpC,qBAAe,OAAO,GAAG;AAAA,IAC3B;AAAA,EACF,CAAC;AACD,SAAO;AACT;AAaA,eAAsB,mBAAsB,SAK7B;AACb,QAAM,EAAE,KAAK,SAAS,IAAI,KAAK,IAAI;AACnC,MAAI,QAAQ,IAAI,gCAAgC,KAAK;AACnD,WAAO,GAAG;AAAA,EACZ;AAEA,MAAI,KAAM,QAAO,iBAAiB,MAAM,KAAK,EAAE;AAE/C,MAAI,OAAO,SAAS,wBAAwB,YAAY;AACtD,WAAO,GAAG;AAAA,EACZ;AACA,SAAO,QAAQ,oBAAoB,KAAK,EAAE;AAC5C;AAGA,eAAe,iBAAoB,MAAgB,KAAa,IAAkC;AAChG,QAAM,CAAC,IAAI,EAAE,IAAI,QAAQ,GAAG;AAC5B,QAAM,SAAS,MAAM,KAAK,QAAQ;AAClC,MAAI;AACF,UAAM,OAAO,MAAM,OAAO;AAG1B,UAAM,OAAO,MAAM,wCAAwC,CAAC,IAAI,EAAE,CAAC;AACnE,QAAI;AACF,YAAM,SAAS,MAAM,GAAG;AACxB,YAAM,OAAO,MAAM,QAAQ;AAC3B,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,YAAM,OAAO,MAAM,UAAU;AAC7B,YAAM;AAAA,IACR;AAAA,EACF,UAAE;AACA,WAAO,QAAQ;AAAA,EACjB;AACF;AAGO,SAAS,8BAAoC;AAClD,iBAAe,MAAM;AACvB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/integrations/github/project-lock.ts"],"sourcesContent":["/**\n * Per-(project, user) lock that serializes the worktree/commit/push/PR flows.\n *\n * The push/PR flows temporarily rewrite the sandbox git remote to a tokenized\n * URL and scrub it again in a `finally`; two concurrent operations on the same\n * `(project, user)` sandbox could interleave those rewrites and leak a tokenized\n * remote. Serializing per `(project, user)` removes that race.\n *\n * There are two layers:\n * 1. An **in-process** promise-chain mutex keyed by the lock key, so repeated\n * same-replica callers stay cheap and never touch the database for ordering.\n * 2. The factory storage backend's **`withDistributedLock` capability** (pg:\n * transaction-scoped advisory locks) so that *different replicas*\n * operating on the same key also serialize. Backends without the\n * capability (libsql: local single-writer) fall back to the in-process\n * mutex alone — correct for single-replica deployments.\n *\n * Set `MASTRACODE_DISTRIBUTED_LOCK=0` to force-disable the distributed layer\n * (local dev, single replica) and fall back to the pure in-process mutex.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { FactoryStorage } from '@mastra/core/storage';\n\n/** Minimal pg pool surface used by the distributed lock (for testability). */\nexport interface LockPool {\n connect(): Promise<LockClient>;\n}\nexport interface LockClient {\n query(sql: string, params?: unknown[]): Promise<unknown>;\n release(): void;\n}\n\nconst inProcessLocks = new Map<string, Promise<unknown>>();\n\n/**\n * Default hard cap on how long a critical section may run inside the project\n * lock. Prevents an untimed outbound call (sandbox HTTP, GitHub App API, git\n * push, etc.) from pinning both the advisory lock and the pg pool connection\n * indefinitely — the failure mode behind the 2025-07-23 shipyard 30s plateau\n * incident. Callers can override per call via `timeoutMs`.\n */\nexport const DEFAULT_PROJECT_LOCK_TIMEOUT_MS = 60_000;\n\n/** Thrown when the critical section under `withProjectLock` exceeds the timeout. */\nexport class ProjectLockTimeoutError extends Error {\n readonly key: string;\n readonly timeoutMs: number;\n constructor(key: string, timeoutMs: number) {\n super(`Project lock critical section for \"${key}\" exceeded ${timeoutMs}ms`);\n this.name = 'ProjectLockTimeoutError';\n this.key = key;\n this.timeoutMs = timeoutMs;\n }\n}\n\n/**\n * Race `fn()` against a timeout, throwing `ProjectLockTimeoutError` if the\n * timeout fires first. `fn()` receives an `AbortSignal` so it can (optionally)\n * abort in-flight outbound I/O when the timeout trips. If `fn()` ignores the\n * signal it will still resolve/reject eventually — but the caller sees the\n * timeout error immediately, which is what lets the outer lock wrapper roll\n * back and release the connection.\n */\nasync function runWithTimeout<T>(key: string, timeoutMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n const abortError = new Promise<never>((_, reject) => {\n controller.signal.addEventListener('abort', () => reject(new ProjectLockTimeoutError(key, timeoutMs)), {\n once: true,\n });\n });\n const work = fn(controller.signal);\n // If `fn` eventually rejects (e.g. its own fetch observes the abort signal\n // after we already rejected via timeout), swallow it — the outer caller\n // has already been rejected with our timeout error and we don't want an\n // unhandled rejection.\n work.catch(() => {});\n try {\n return await Promise.race([work, abortError]);\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * True when the cross-replica lock layer should be used: not force-disabled\n * via env, and the factory storage backend exposes the\n * `withDistributedLock` capability.\n */\nexport function isDistributedLockEnabled(storage: FactoryStorage | undefined): boolean {\n if (process.env.MASTRACODE_DISTRIBUTED_LOCK === '0') return false;\n return typeof storage?.withDistributedLock === 'function';\n}\n\n/**\n * Hash a lock key into the two signed 32-bit integers that the two-arg form of\n * `pg_advisory_xact_lock(int4, int4)` expects. Using two int4 args (rather than\n * one int8) keeps the key inside the GitHub-feature advisory-lock namespace and\n * avoids collisions with other single-int8 advisory locks.\n */\nexport function hashKey(key: string): [number, number] {\n const digest = createHash('sha256').update(key).digest();\n // Read two independent 32-bit halves as signed int4 values.\n const a = digest.readInt32BE(0);\n const b = digest.readInt32BE(4);\n return [a, b];\n}\n\n/**\n * Run `fn` while holding the lock for `key`. Same-replica callers serialize via\n * the in-process mutex; cross-replica callers additionally serialize via a\n * Postgres transaction-scoped advisory lock (unless disabled).\n *\n * The in-process chain swallows rejections so one failed operation does not\n * poison the lock for subsequent callers.\n */\nexport function withProjectLock<T>(options: {\n key: string;\n /** Factory storage backend supplying the `withDistributedLock` capability, when available. */\n storage?: FactoryStorage;\n fn: (signal: AbortSignal) => Promise<T>;\n /** Test seam: fake pg pool standing in for the distributed layer. */\n pool?: LockPool;\n /**\n * Hard cap on the critical section. Defaults to\n * {@link DEFAULT_PROJECT_LOCK_TIMEOUT_MS}. On timeout `fn`'s abort signal\n * fires and the outer lock throws `ProjectLockTimeoutError`, releasing the\n * advisory lock + pool connection.\n */\n timeoutMs?: number;\n}): Promise<T> {\n const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;\n const prev = inProcessLocks.get(key) ?? Promise.resolve();\n const run = () => withDbAdvisoryLock({ key, storage, fn, pool, timeoutMs });\n const next = prev.then(run, run);\n const tail = next.then(\n () => undefined,\n () => undefined,\n );\n inProcessLocks.set(key, tail);\n // Drop the entry once this operation settles, but only if no later caller has\n // chained onto it in the meantime — otherwise we'd evict a live waiter's tail.\n // This keeps the map from growing unbounded across many distinct project keys.\n void tail.then(() => {\n if (inProcessLocks.get(key) === tail) {\n inProcessLocks.delete(key);\n }\n });\n return next;\n}\n\n/**\n * Acquire only the cross-replica lock for `key` and run `fn` under it. This is\n * the distributed serialization layer; `withProjectLock` wraps it with an\n * in-process mutex for same-replica callers. Delegates to the factory storage\n * backend's `withDistributedLock` capability; backends without it (or a\n * force-disabled env) run `fn` directly — the in-process mutex still holds.\n *\n * `poolOverride` keeps the pg advisory-lock path directly testable with a fake\n * pool (each simulated replica has its own in-process state but shares one\n * database).\n */\nexport async function withDbAdvisoryLock<T>(options: {\n key: string;\n storage?: FactoryStorage;\n fn: (signal: AbortSignal) => Promise<T>;\n pool?: LockPool;\n timeoutMs?: number;\n}): Promise<T> {\n const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;\n if (process.env.MASTRACODE_DISTRIBUTED_LOCK === '0') {\n return runWithTimeout(key, timeoutMs, fn);\n }\n\n if (pool) return advisoryLockOver(pool, key, timeoutMs, fn);\n\n if (typeof storage?.withDistributedLock !== 'function') {\n return runWithTimeout(key, timeoutMs, fn);\n }\n // Wrap the backend-provided lock so the critical section is bounded\n // regardless of whether the backend implements its own timeout. The\n // backend still owns lock acquisition/release; we own the timeout on\n // `fn`.\n return storage.withDistributedLock(key, () => runWithTimeout(key, timeoutMs, fn));\n}\n\n/** The pg advisory-lock body, kept for the `poolOverride` test seam. */\nasync function advisoryLockOver<T>(\n pool: LockPool,\n key: string,\n timeoutMs: number,\n fn: (signal: AbortSignal) => Promise<T>,\n): Promise<T> {\n const [k1, k2] = hashKey(key);\n const client = await pool.connect();\n try {\n await client.query('BEGIN');\n // Blocks until no other transaction holds this advisory key. Auto-released\n // when the transaction ends below.\n await client.query('SELECT pg_advisory_xact_lock($1, $2)', [k1, k2]);\n try {\n const result = await runWithTimeout(key, timeoutMs, fn);\n await client.query('COMMIT');\n return result;\n } catch (err) {\n // COMMIT/ROLLBACK below releases the advisory lock. If the underlying\n // connection is already dead (e.g. Neon killed it after IIT), the\n // rollback throws — we swallow that specifically so the original error\n // reaches the caller.\n try {\n await client.query('ROLLBACK');\n } catch {\n /* connection already gone; nothing to roll back */\n }\n throw err;\n }\n } finally {\n client.release();\n }\n}\n\n/** For tests: clear the in-process lock chains. */\nexport function __resetProjectLocksForTests(): void {\n inProcessLocks.clear();\n}\n"],"mappings":"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","names":[]}
|
|
@@ -93,6 +93,34 @@ async function clearGithubPat(storage, orgId, kind = "default") {
|
|
|
93
93
|
// src/integrations/github/project-lock.ts
|
|
94
94
|
import { createHash } from "crypto";
|
|
95
95
|
var inProcessLocks = /* @__PURE__ */ new Map();
|
|
96
|
+
var DEFAULT_PROJECT_LOCK_TIMEOUT_MS = 6e4;
|
|
97
|
+
var ProjectLockTimeoutError = class extends Error {
|
|
98
|
+
key;
|
|
99
|
+
timeoutMs;
|
|
100
|
+
constructor(key, timeoutMs) {
|
|
101
|
+
super(`Project lock critical section for "${key}" exceeded ${timeoutMs}ms`);
|
|
102
|
+
this.name = "ProjectLockTimeoutError";
|
|
103
|
+
this.key = key;
|
|
104
|
+
this.timeoutMs = timeoutMs;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
async function runWithTimeout(key, timeoutMs, fn) {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
110
|
+
const abortError = new Promise((_, reject) => {
|
|
111
|
+
controller.signal.addEventListener("abort", () => reject(new ProjectLockTimeoutError(key, timeoutMs)), {
|
|
112
|
+
once: true
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
const work = fn(controller.signal);
|
|
116
|
+
work.catch(() => {
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
return await Promise.race([work, abortError]);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
96
124
|
function hashKey(key) {
|
|
97
125
|
const digest = createHash("sha256").update(key).digest();
|
|
98
126
|
const a = digest.readInt32BE(0);
|
|
@@ -100,9 +128,9 @@ function hashKey(key) {
|
|
|
100
128
|
return [a, b];
|
|
101
129
|
}
|
|
102
130
|
function withProjectLock(options) {
|
|
103
|
-
const { key, storage, fn, pool } = options;
|
|
131
|
+
const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;
|
|
104
132
|
const prev = inProcessLocks.get(key) ?? Promise.resolve();
|
|
105
|
-
const run = () => withDbAdvisoryLock({ key, storage, fn, pool });
|
|
133
|
+
const run = () => withDbAdvisoryLock({ key, storage, fn, pool, timeoutMs });
|
|
106
134
|
const next = prev.then(run, run);
|
|
107
135
|
const tail = next.then(
|
|
108
136
|
() => void 0,
|
|
@@ -117,28 +145,31 @@ function withProjectLock(options) {
|
|
|
117
145
|
return next;
|
|
118
146
|
}
|
|
119
147
|
async function withDbAdvisoryLock(options) {
|
|
120
|
-
const { key, storage, fn, pool } = options;
|
|
148
|
+
const { key, storage, fn, pool, timeoutMs = DEFAULT_PROJECT_LOCK_TIMEOUT_MS } = options;
|
|
121
149
|
if (process.env.MASTRACODE_DISTRIBUTED_LOCK === "0") {
|
|
122
|
-
return fn
|
|
150
|
+
return runWithTimeout(key, timeoutMs, fn);
|
|
123
151
|
}
|
|
124
|
-
if (pool) return advisoryLockOver(pool, key, fn);
|
|
152
|
+
if (pool) return advisoryLockOver(pool, key, timeoutMs, fn);
|
|
125
153
|
if (typeof storage?.withDistributedLock !== "function") {
|
|
126
|
-
return fn
|
|
154
|
+
return runWithTimeout(key, timeoutMs, fn);
|
|
127
155
|
}
|
|
128
|
-
return storage.withDistributedLock(key, fn);
|
|
156
|
+
return storage.withDistributedLock(key, () => runWithTimeout(key, timeoutMs, fn));
|
|
129
157
|
}
|
|
130
|
-
async function advisoryLockOver(pool, key, fn) {
|
|
158
|
+
async function advisoryLockOver(pool, key, timeoutMs, fn) {
|
|
131
159
|
const [k1, k2] = hashKey(key);
|
|
132
160
|
const client = await pool.connect();
|
|
133
161
|
try {
|
|
134
162
|
await client.query("BEGIN");
|
|
135
163
|
await client.query("SELECT pg_advisory_xact_lock($1, $2)", [k1, k2]);
|
|
136
164
|
try {
|
|
137
|
-
const result = await fn
|
|
165
|
+
const result = await runWithTimeout(key, timeoutMs, fn);
|
|
138
166
|
await client.query("COMMIT");
|
|
139
167
|
return result;
|
|
140
168
|
} catch (err) {
|
|
141
|
-
|
|
169
|
+
try {
|
|
170
|
+
await client.query("ROLLBACK");
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
142
173
|
throw err;
|
|
143
174
|
}
|
|
144
175
|
} finally {
|