@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/capabilities/intake.d.ts +45 -0
  3. package/dist/capabilities/intake.d.ts.map +1 -1
  4. package/dist/factory.js +920 -549
  5. package/dist/factory.js.map +1 -1
  6. package/dist/index.js +920 -549
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/github/integration.d.ts.map +1 -1
  9. package/dist/integrations/github/integration.js +126 -11
  10. package/dist/integrations/github/integration.js.map +1 -1
  11. package/dist/integrations/github/project-lock.d.ts +24 -2
  12. package/dist/integrations/github/project-lock.d.ts.map +1 -1
  13. package/dist/integrations/github/project-lock.js +43 -10
  14. package/dist/integrations/github/project-lock.js.map +1 -1
  15. package/dist/integrations/github/routes.js +41 -10
  16. package/dist/integrations/github/routes.js.map +1 -1
  17. package/dist/integrations/linear/agent-tools.js.map +1 -1
  18. package/dist/integrations/linear/integration.d.ts.map +1 -1
  19. package/dist/integrations/linear/integration.js +60 -1
  20. package/dist/integrations/linear/integration.js.map +1 -1
  21. package/dist/integrations/linear/routes.js.map +1 -1
  22. package/dist/integrations/platform/github/event-worker.d.ts.map +1 -1
  23. package/dist/integrations/platform/github/event-worker.js +7 -1
  24. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  25. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  26. package/dist/integrations/platform/github/integration.js +104 -12
  27. package/dist/integrations/platform/github/integration.js.map +1 -1
  28. package/dist/integrations/platform/linear/integration.d.ts.map +1 -1
  29. package/dist/integrations/platform/linear/integration.js +91 -0
  30. package/dist/integrations/platform/linear/integration.js.map +1 -1
  31. package/dist/routes/fs.d.ts +16 -0
  32. package/dist/routes/fs.d.ts.map +1 -1
  33. package/dist/routes/fs.js +103 -3
  34. package/dist/routes/fs.js.map +1 -1
  35. package/dist/routes/oauth.js +564 -478
  36. package/dist/routes/oauth.js.map +1 -1
  37. package/dist/routes/projects.d.ts.map +1 -1
  38. package/dist/routes/projects.js +1 -0
  39. package/dist/routes/projects.js.map +1 -1
  40. package/dist/routes/surface.d.ts.map +1 -1
  41. package/dist/routes/surface.js +717 -526
  42. package/dist/routes/surface.js.map +1 -1
  43. package/dist/storage/domains/source-control/base.d.ts +0 -1
  44. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  45. package/dist/storage/domains/source-control/base.js +6 -7
  46. package/dist/storage/domains/source-control/base.js.map +1 -1
  47. 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;IACrB,qEAAqE;IACrE,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB,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,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC;IACrB,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB,GAAG,OAAO,CAAC,CAAC,CAAC,CAYb;AAwBD,mDAAmD;AACnD,wBAAgB,2BAA2B,IAAI,IAAI,CAElD"}
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
- await client.query("ROLLBACK");
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
- await client.query("ROLLBACK");
169
+ try {
170
+ await client.query("ROLLBACK");
171
+ } catch {
172
+ }
142
173
  throw err;
143
174
  }
144
175
  } finally {