@mastra/factory 0.24.0-alpha.11 → 0.24.0-alpha.13

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 (165) hide show
  1. package/README.md +4 -4
  2. package/dist/environment/build-runner.js +2 -1
  3. package/dist/environment/build-runner.js.map +1 -1
  4. package/dist/environment/build-workflow.js +1 -1
  5. package/dist/environment/build-workflow.js.map +1 -1
  6. package/dist/environment/build.js +16 -14
  7. package/dist/environment/build.js.map +1 -1
  8. package/dist/factory.d.ts.map +1 -1
  9. package/dist/factory.js +25 -1
  10. package/dist/factory.js.map +1 -1
  11. package/dist/integrations/github/app-identity.js.map +1 -1
  12. package/dist/integrations/github/integration.js +1 -1
  13. package/dist/integrations/github/integration.js.map +1 -1
  14. package/dist/integrations/github/reconcile-worker.js +1 -1
  15. package/dist/integrations/github/reconcile-worker.js.map +1 -1
  16. package/dist/integrations/github/routes.d.ts.map +1 -1
  17. package/dist/integrations/github/routes.js +6 -3
  18. package/dist/integrations/github/routes.js.map +1 -1
  19. package/dist/integrations/github/rules.js +3 -2
  20. package/dist/integrations/github/rules.js.map +1 -1
  21. package/dist/integrations/github/sandbox.js +2 -2
  22. package/dist/integrations/github/sandbox.js.map +1 -1
  23. package/dist/integrations/gitlab/api.js.map +1 -1
  24. package/dist/integrations/gitlab/integration.js.map +1 -1
  25. package/dist/integrations/gitlab/reconciliation-config.js +2 -1
  26. package/dist/integrations/gitlab/reconciliation-config.js.map +1 -1
  27. package/dist/integrations/gitlab/rules.js.map +1 -1
  28. package/dist/integrations/incidentio/api.js.map +1 -1
  29. package/dist/integrations/incidentio/integration.js.map +1 -1
  30. package/dist/integrations/incidentio/rules.js.map +1 -1
  31. package/dist/integrations/issue-reconcile-worker.js +1 -1
  32. package/dist/integrations/issue-reconcile-worker.js.map +1 -1
  33. package/dist/integrations/issue-reconciler.js +1 -1
  34. package/dist/integrations/issue-reconciler.js.map +1 -1
  35. package/dist/integrations/jira/api.js.map +1 -1
  36. package/dist/integrations/jira/integration.js.map +1 -1
  37. package/dist/integrations/jira/rules.js.map +1 -1
  38. package/dist/integrations/linear/integration.js.map +1 -1
  39. package/dist/integrations/linear/rules.js.map +1 -1
  40. package/dist/integrations/platform/api-client.js.map +1 -1
  41. package/dist/integrations/platform/github/event-worker.js +1 -1
  42. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  43. package/dist/integrations/platform/github/integration.js +2 -2
  44. package/dist/integrations/platform/github/integration.js.map +1 -1
  45. package/dist/integrations/platform/gitlab/event-worker.js.map +1 -1
  46. package/dist/integrations/platform/gitlab/integration.js.map +1 -1
  47. package/dist/integrations/platform/incidentio/integration.js +2 -1
  48. package/dist/integrations/platform/incidentio/integration.js.map +1 -1
  49. package/dist/integrations/platform/jira/integration.js.map +1 -1
  50. package/dist/integrations/platform/linear/event-worker.js +1 -1
  51. package/dist/integrations/platform/linear/event-worker.js.map +1 -1
  52. package/dist/integrations/platform/linear/integration.js.map +1 -1
  53. package/dist/integrations/slack/connect-route.js +1 -1
  54. package/dist/integrations/slack/connect-route.js.map +1 -1
  55. package/dist/integrations/slack/feed-publisher.js +1 -1
  56. package/dist/integrations/slack/feed-publisher.js.map +1 -1
  57. package/dist/integrations/slack/integration.js +1 -1
  58. package/dist/integrations/slack/integration.js.map +1 -1
  59. package/dist/integrations/slack/slack.js +2 -1
  60. package/dist/integrations/slack/slack.js.map +1 -1
  61. package/dist/integrations/workos/integration.js.map +1 -1
  62. package/dist/packages/_internals/workspace/dist/index.js +18 -1
  63. package/dist/packages/_internals/workspace/dist/index.js.map +1 -1
  64. package/dist/routes/attention-activity.js.map +1 -1
  65. package/dist/routes/attention-parked.js.map +1 -1
  66. package/dist/routes/attention-providers.js.map +1 -1
  67. package/dist/routes/automation-runs.js.map +1 -1
  68. package/dist/routes/contracts.js +1 -1
  69. package/dist/routes/contracts.js.map +1 -1
  70. package/dist/routes/custom-provider-source.js.map +1 -1
  71. package/dist/routes/fs.d.ts +31 -23
  72. package/dist/routes/fs.d.ts.map +1 -1
  73. package/dist/routes/fs.js +218 -64
  74. package/dist/routes/fs.js.map +1 -1
  75. package/dist/routes/intake.js +2 -1
  76. package/dist/routes/intake.js.map +1 -1
  77. package/dist/routes/knowledge.js +1 -1
  78. package/dist/routes/knowledge.js.map +1 -1
  79. package/dist/routes/oauth.js +3 -2
  80. package/dist/routes/oauth.js.map +1 -1
  81. package/dist/routes/projects.js +32 -30
  82. package/dist/routes/projects.js.map +1 -1
  83. package/dist/routes/session-access.d.ts +55 -0
  84. package/dist/routes/session-access.d.ts.map +1 -0
  85. package/dist/routes/session-access.js +165 -0
  86. package/dist/routes/session-access.js.map +1 -0
  87. package/dist/routes/skills.js.map +1 -1
  88. package/dist/routes/source-control-sessions.d.ts +5 -0
  89. package/dist/routes/source-control-sessions.d.ts.map +1 -1
  90. package/dist/routes/source-control-sessions.js +87 -10
  91. package/dist/routes/source-control-sessions.js.map +1 -1
  92. package/dist/routes/surface.d.ts +5 -0
  93. package/dist/routes/surface.d.ts.map +1 -1
  94. package/dist/routes/surface.js +10 -4
  95. package/dist/routes/surface.js.map +1 -1
  96. package/dist/routes/tenant-credentials.js.map +1 -1
  97. package/dist/routes/work-items.js.map +1 -1
  98. package/dist/rules/binding-context.js +4 -2
  99. package/dist/rules/binding-context.js.map +1 -1
  100. package/dist/rules/dispatcher.js +4 -4
  101. package/dist/rules/dispatcher.js.map +1 -1
  102. package/dist/rules/start-coordinator.js.map +1 -1
  103. package/dist/rules/tools.js +2 -2
  104. package/dist/rules/tools.js.map +1 -1
  105. package/dist/rules/transition-service.js.map +1 -1
  106. package/dist/sandbox/callback-factory-sandbox.js.map +1 -1
  107. package/dist/sandbox/local-factory-sandbox.js.map +1 -1
  108. package/dist/sandbox/session-retirement.js.map +1 -1
  109. package/dist/session/live-sessions.js.map +1 -1
  110. package/dist/session/memory-settings-hydration.js +2 -1
  111. package/dist/session/memory-settings-hydration.js.map +1 -1
  112. package/dist/session/resource-migration.d.ts +18 -0
  113. package/dist/session/resource-migration.d.ts.map +1 -1
  114. package/dist/session/resource-migration.js +24 -1
  115. package/dist/session/resource-migration.js.map +1 -1
  116. package/dist/session/session-threads.d.ts +9 -0
  117. package/dist/session/session-threads.d.ts.map +1 -1
  118. package/dist/session/session-threads.js +24 -4
  119. package/dist/session/session-threads.js.map +1 -1
  120. package/dist/spa-static.js.map +1 -1
  121. package/dist/state-signing.js +1 -1
  122. package/dist/state-signing.js.map +1 -1
  123. package/dist/storage/domains/audit/base.js.map +1 -1
  124. package/dist/storage/domains/audit/domain.js.map +1 -1
  125. package/dist/storage/domains/channel-identity/base.js.map +1 -1
  126. package/dist/storage/domains/comments/base.js.map +1 -1
  127. package/dist/storage/domains/comments/domain.js.map +1 -1
  128. package/dist/storage/domains/comments/feed-context.js.map +1 -1
  129. package/dist/storage/domains/credentials/base.js.map +1 -1
  130. package/dist/storage/domains/custom-providers/base.js.map +1 -1
  131. package/dist/storage/domains/intake/base.js.map +1 -1
  132. package/dist/storage/domains/integrations/base.js.map +1 -1
  133. package/dist/storage/domains/memory-settings/base.js.map +1 -1
  134. package/dist/storage/domains/model-defaults/base.js.map +1 -1
  135. package/dist/storage/domains/projects/base.js.map +1 -1
  136. package/dist/storage/domains/queue-health/base.js.map +1 -1
  137. package/dist/storage/domains/source-control/base.d.ts +15 -0
  138. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  139. package/dist/storage/domains/source-control/base.js +29 -0
  140. package/dist/storage/domains/source-control/base.js.map +1 -1
  141. package/dist/storage/domains/source-control/inmemory.d.ts +5 -1
  142. package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
  143. package/dist/storage/domains/source-control/inmemory.js +7 -0
  144. package/dist/storage/domains/source-control/inmemory.js.map +1 -1
  145. package/dist/storage/domains/work-items/base.js +2 -1
  146. package/dist/storage/domains/work-items/base.js.map +1 -1
  147. package/dist/storage/domains/work-items/board-version.js.map +1 -1
  148. package/dist/storage/domains/work-items/metrics.js.map +1 -1
  149. package/dist/tools/messaging-tools.d.ts.map +1 -1
  150. package/dist/tools/messaging-tools.js +201 -73
  151. package/dist/tools/messaging-tools.js.map +1 -1
  152. package/dist/tools/run-watch.d.ts +2 -0
  153. package/dist/tools/run-watch.d.ts.map +1 -1
  154. package/dist/tools/run-watch.js +42 -2
  155. package/dist/tools/run-watch.js.map +1 -1
  156. package/dist/tools/session-tools.d.ts.map +1 -1
  157. package/dist/tools/session-tools.js +25 -10
  158. package/dist/tools/session-tools.js.map +1 -1
  159. package/dist/tools/sessions-state-processor.js +2 -2
  160. package/dist/tools/sessions-state-processor.js.map +1 -1
  161. package/dist/workspace.d.ts +5 -2
  162. package/dist/workspace.d.ts.map +1 -1
  163. package/dist/workspace.js +26 -13
  164. package/dist/workspace.js.map +1 -1
  165. package/package.json +9 -9
@@ -1 +1 @@
1
- {"version":3,"file":"board-version.js","names":["#scopes"],"sources":["../../../../src/storage/domains/work-items/board-version.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type { CollectionSchema, CollectionWhere, FactoryStorageOps } from '@mastra/core/storage';\n\n/**\n * One row per project whose `version` is replaced after every committed write to that project's\n * `work_items`. Readers of the full card list compare it against the version their snapshot was\n * taken at, so an unchanged board costs one indexed row read instead of every card.\n */\nexport const FACTORY_BOARD_VERSIONS_SCHEMA: CollectionSchema = {\n name: 'factory_board_versions',\n columns: {\n id: { type: 'uuid-pk' },\n org_id: { type: 'text' },\n factory_project_id: { type: 'text' },\n version: { type: 'text' },\n updated_at: { type: 'timestamp' },\n },\n uniqueIndexes: [{ name: 'factory_board_versions_scope_unique', columns: ['org_id', 'factory_project_id'] }],\n};\n\n/** A project, or every project in an org when a write's filter only names the org. */\nexport interface BoardScope {\n orgId: string;\n factoryProjectId?: string;\n}\n\nconst scopeKey = (scope: BoardScope) => `${scope.orgId}\\u0000${scope.factoryProjectId ?? ''}`;\n\nfunction scopeOfRow(row: Record<string, unknown> | null): BoardScope | undefined {\n if (!row || typeof row.org_id !== 'string') return undefined;\n return typeof row.factory_project_id === 'string'\n ? { orgId: row.org_id, factoryProjectId: row.factory_project_id }\n : { orgId: row.org_id };\n}\n\nfunction scopeOfWhere(where: CollectionWhere): BoardScope | undefined {\n if (typeof where.org_id !== 'string') return undefined;\n return typeof where.factory_project_id === 'string'\n ? { orgId: where.org_id, factoryProjectId: where.factory_project_id }\n : { orgId: where.org_id };\n}\n\n/**\n * Wrap `ops` so every write to `work_items` reports the board it changed, once the write has\n * resolved. Writes outside any tenant (`dangerouslyClearAll`) report nothing.\n */\nexport function trackBoardWrites(\n ops: FactoryStorageOps,\n changed: (scope: BoardScope) => void | Promise<void>,\n): FactoryStorageOps {\n const report = async (scope: BoardScope | undefined) => {\n if (scope) await changed(scope);\n };\n return new Proxy(ops, {\n get(target, property, receiver) {\n const value = Reflect.get(target, property, receiver);\n if (typeof value !== 'function') return value;\n switch (property) {\n case 'updateAtomic':\n return async (collection: string, where: unknown, updater: (row: unknown) => unknown, ...args: unknown[]) => {\n let patched = false;\n const row = await value.call(\n target,\n collection,\n where,\n async (current: unknown) => {\n const patch = await updater(current);\n patched = patch != null;\n return patch;\n },\n ...args,\n );\n if (collection === 'work_items' && patched) await report(scopeOfRow(row));\n return row;\n };\n case 'insertOne':\n case 'upsertOne':\n return async (collection: string, ...args: unknown[]) => {\n const row = await value.call(target, collection, ...args);\n if (collection === 'work_items') await report(scopeOfRow(row));\n return row;\n };\n case 'updateMany':\n case 'deleteMany':\n return async (collection: string, where: CollectionWhere, ...args: unknown[]) => {\n const count: number = await value.call(target, collection, where, ...args);\n if (collection === 'work_items' && count > 0) await report(scopeOfWhere(where));\n return count;\n };\n default:\n return value.bind(target);\n }\n },\n });\n}\n\n/** Collects the boards a transaction changed so they can be bumped once it has committed. */\nexport class BoardScopes {\n readonly #scopes = new Map<string, BoardScope>();\n\n add = (scope: BoardScope): void => {\n this.#scopes.set(scopeKey(scope), scope);\n };\n\n values(): BoardScope[] {\n return [...this.#scopes.values()];\n }\n}\n\nexport async function bumpBoardVersions(ops: FactoryStorageOps, scopes: readonly BoardScope[]): Promise<void> {\n for (const scope of scopes) {\n const version = randomUUID();\n const updated_at = new Date();\n if (scope.factoryProjectId) {\n await ops.upsertOne(FACTORY_BOARD_VERSIONS_SCHEMA.name, ['org_id', 'factory_project_id'], {\n org_id: scope.orgId,\n factory_project_id: scope.factoryProjectId,\n version,\n updated_at,\n });\n } else {\n // Projects without a row yet have no version, which never matches a snapshot.\n await ops.updateMany(FACTORY_BOARD_VERSIONS_SCHEMA.name, { org_id: scope.orgId }, { version, updated_at });\n }\n }\n}\n\nexport async function readBoardVersion(\n ops: FactoryStorageOps,\n orgId: string,\n factoryProjectId: string,\n): Promise<string | undefined> {\n const row = await ops.findOne<{ version: string }>(FACTORY_BOARD_VERSIONS_SCHEMA.name, {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n });\n return row?.version;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"board-version.js","names":[],"sources":["../../../../src/storage/domains/work-items/board-version.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type { CollectionSchema, CollectionWhere, FactoryStorageOps } from '@mastra/core/storage';\n\n/**\n * One row per project whose `version` is replaced after every committed write to that project's\n * `work_items`. Readers of the full card list compare it against the version their snapshot was\n * taken at, so an unchanged board costs one indexed row read instead of every card.\n */\nexport const FACTORY_BOARD_VERSIONS_SCHEMA: CollectionSchema = {\n name: 'factory_board_versions',\n columns: {\n id: { type: 'uuid-pk' },\n org_id: { type: 'text' },\n factory_project_id: { type: 'text' },\n version: { type: 'text' },\n updated_at: { type: 'timestamp' },\n },\n uniqueIndexes: [{ name: 'factory_board_versions_scope_unique', columns: ['org_id', 'factory_project_id'] }],\n};\n\n/** A project, or every project in an org when a write's filter only names the org. */\nexport interface BoardScope {\n orgId: string;\n factoryProjectId?: string;\n}\n\nconst scopeKey = (scope: BoardScope) => `${scope.orgId}\\u0000${scope.factoryProjectId ?? ''}`;\n\nfunction scopeOfRow(row: Record<string, unknown> | null): BoardScope | undefined {\n if (!row || typeof row.org_id !== 'string') return undefined;\n return typeof row.factory_project_id === 'string'\n ? { orgId: row.org_id, factoryProjectId: row.factory_project_id }\n : { orgId: row.org_id };\n}\n\nfunction scopeOfWhere(where: CollectionWhere): BoardScope | undefined {\n if (typeof where.org_id !== 'string') return undefined;\n return typeof where.factory_project_id === 'string'\n ? { orgId: where.org_id, factoryProjectId: where.factory_project_id }\n : { orgId: where.org_id };\n}\n\n/**\n * Wrap `ops` so every write to `work_items` reports the board it changed, once the write has\n * resolved. Writes outside any tenant (`dangerouslyClearAll`) report nothing.\n */\nexport function trackBoardWrites(\n ops: FactoryStorageOps,\n changed: (scope: BoardScope) => void | Promise<void>,\n): FactoryStorageOps {\n const report = async (scope: BoardScope | undefined) => {\n if (scope) await changed(scope);\n };\n return new Proxy(ops, {\n get(target, property, receiver) {\n const value = Reflect.get(target, property, receiver);\n if (typeof value !== 'function') return value;\n switch (property) {\n case 'updateAtomic':\n return async (collection: string, where: unknown, updater: (row: unknown) => unknown, ...args: unknown[]) => {\n let patched = false;\n const row = await value.call(\n target,\n collection,\n where,\n async (current: unknown) => {\n const patch = await updater(current);\n patched = patch != null;\n return patch;\n },\n ...args,\n );\n if (collection === 'work_items' && patched) await report(scopeOfRow(row));\n return row;\n };\n case 'insertOne':\n case 'upsertOne':\n return async (collection: string, ...args: unknown[]) => {\n const row = await value.call(target, collection, ...args);\n if (collection === 'work_items') await report(scopeOfRow(row));\n return row;\n };\n case 'updateMany':\n case 'deleteMany':\n return async (collection: string, where: CollectionWhere, ...args: unknown[]) => {\n const count: number = await value.call(target, collection, where, ...args);\n if (collection === 'work_items' && count > 0) await report(scopeOfWhere(where));\n return count;\n };\n default:\n return value.bind(target);\n }\n },\n });\n}\n\n/** Collects the boards a transaction changed so they can be bumped once it has committed. */\nexport class BoardScopes {\n readonly #scopes = new Map<string, BoardScope>();\n\n add = (scope: BoardScope): void => {\n this.#scopes.set(scopeKey(scope), scope);\n };\n\n values(): BoardScope[] {\n return [...this.#scopes.values()];\n }\n}\n\nexport async function bumpBoardVersions(ops: FactoryStorageOps, scopes: readonly BoardScope[]): Promise<void> {\n for (const scope of scopes) {\n const version = randomUUID();\n const updated_at = new Date();\n if (scope.factoryProjectId) {\n await ops.upsertOne(FACTORY_BOARD_VERSIONS_SCHEMA.name, ['org_id', 'factory_project_id'], {\n org_id: scope.orgId,\n factory_project_id: scope.factoryProjectId,\n version,\n updated_at,\n });\n } else {\n // Projects without a row yet have no version, which never matches a snapshot.\n await ops.updateMany(FACTORY_BOARD_VERSIONS_SCHEMA.name, { org_id: scope.orgId }, { version, updated_at });\n }\n }\n}\n\nexport async function readBoardVersion(\n ops: FactoryStorageOps,\n orgId: string,\n factoryProjectId: string,\n): Promise<string | undefined> {\n const row = await ops.findOne<{ version: string }>(FACTORY_BOARD_VERSIONS_SCHEMA.name, {\n org_id: orgId,\n factory_project_id: factoryProjectId,\n });\n return row?.version;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"metrics.js","names":[],"sources":["../../../../src/storage/domains/work-items/metrics.ts"],"sourcesContent":["/**\n * Aggregation math for the Factory Overview page.\n *\n * Pure functions over `work_items` rows — throughput, lead time, in-flight\n * count, demand mix and per-stage agent coverage, all read from the\n * server-appended `stageHistory` log. Keeping this DB-free makes the math unit\n * testable and lets the route stay a thin shell.\n *\n * Everything windowed is counted as an event that happened inside the window,\n * never as \"the state the board happens to be in now\", so re-querying a past\n * window always returns the same numbers.\n */\n\nimport { isAgentActor } from './base.js';\nimport type { WorkItemRow } from './base.js';\n\n/** Default window span (days) when the request omits or malforms the range. */\nexport const DEFAULT_METRICS_WINDOW = 30;\n/** Hard cap on the range span (days) — bounds the gap-filled throughput array. */\nexport const MAX_METRICS_WINDOW = 366;\n\nconst DAY_MS = 86_400_000;\n\n/** Terminal stage — items here count as completed, not in-flight. */\nconst DONE_STAGE = 'done';\n\n/** Terminal stage for tracked non-completions — never a completion. */\nconst CANCELED_STAGE = 'canceled';\n\n/**\n * Terminal stages — items holding only these are not in-flight. `done` is a\n * completion (feeds throughput/lead time); `canceled` is a tracked\n * non-completion outcome and feeds neither.\n */\nconst TERMINAL_STAGES = new Set([DONE_STAGE, CANCELED_STAGE]);\n\nconst INTAKE_STAGE = 'intake';\n\n/**\n * Pipeline work excludes the inbox as well as the terminal stages: an intake\n * card is queued, not in flight, and its pass through is the poller filing it.\n */\nfunction isPipelineStage(stage: string): boolean {\n return !TERMINAL_STAGES.has(stage) && stage !== INTAKE_STAGE;\n}\n\n/**\n * Cards the Factory ran: starting a run records its session on the row. The\n * integrations sync every issue and PR of a connected repo into the board and\n * those outnumber the Factory's own work by an order of magnitude, so counting\n * them reports the upstream repo's flow as the Factory's.\n */\nfunction hasFactoryRun(item: WorkItemRow): boolean {\n return Object.keys(item.sessions).length > 0;\n}\n\n/**\n * Flow metrics over the cards the Factory ran ({@link hasFactoryRun}) — synced\n * upstream issues and PRs nobody started a run on are not the Factory's work\n * and are excluded from every field below.\n */\nexport interface FactoryMetrics {\n /**\n * Days the series covers: the requested window clipped to the board's life.\n * Days before the first card could hold no completion, so counting them would\n * drag the per-day rate toward zero on a young board.\n */\n daysCovered: number;\n /** Entries into `done` per UTC day, gap-filled across the covered days. */\n throughput: { date: string; count: number }[];\n /** Card creation → `done` for every completion that landed in the window. */\n leadTime: { medianMs: number | null; p90Ms: number | null; samples: number };\n /** Distinct cards in a pipeline stage — past intake, not yet terminal. */\n wipTotal: number;\n /** Cards created in the window, by source. */\n sourceMix: { source: string; count: number }[];\n /** Per-stage agent coverage over first visits that ended in the window. */\n agentCoverage: {\n stage: string;\n /**\n * First visits to this stage that ended in the window. Repeat visits are\n * excluded from both sides: they are rework, already reported as such, and\n * counting them in the denominator alone caps a fully agent-run stage below\n * 100%.\n */\n passes: number;\n /**\n * Of those: passes an agent finished (`exitedBy` is an agent actor). The\n * entry actor is not required — the rules engine is what queues a card into\n * a stage, so demanding both ends would report 0% for stages agents run\n * end to end. Missing `exitedBy` (entries written before exit stamping)\n * does not count.\n */\n byAgent: number;\n /**\n * Outcomes of the agent-finished passes' items as of the window's end,\n * mutually exclusive, first match wins: `reworked` (a later visit to the\n * same stage — deliberately outranks `done`: a pass that needed a redo is a\n * failed pass even if the item eventually merged), then `done`, then\n * `canceled`, then `inFlight`.\n */\n outcomes: { done: number; canceled: number; reworked: number; inFlight: number };\n }[];\n}\n\nconst DATE_ONLY_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n/** Datetime carrying an explicit `Z` or `±HH:MM` offset. */\nconst ZONED_DATETIME_RE = /(?:[Zz]|[+-]\\d{2}:?\\d{2})$/;\n\nfunction parseRangeParam(value: unknown, boundary: 'from' | 'to'): number | undefined {\n if (typeof value !== 'string' || value.length === 0) return undefined;\n const dateOnly = DATE_ONLY_RE.test(value);\n // Timezone-less datetimes are parsed as server-local by Date.parse, so the\n // window would shift by deployment region — reject them as invalid.\n if (!dateOnly && !ZONED_DATETIME_RE.test(value)) return undefined;\n const time = Date.parse(value);\n if (Number.isNaN(time)) return undefined;\n return boundary === 'to' && dateOnly ? time + DAY_MS : time;\n}\n\nfunction utcDayStart(time: number): number {\n return Date.parse(`${utcDay(time)}T00:00:00Z`);\n}\n\n/**\n * Resolve untrusted `from`/`to` into a bounded half-open UTC window. A date-only\n * `to` covers the whole day; an open/future end resolves to the end of the\n * current UTC day (not `now`) so an event at this instant stays inside the\n * window instead of on its excluded edge.\n */\nexport function parseMetricsRange(\n fromParam: unknown,\n toParam: unknown,\n now: Date,\n): { windowStart: number; windowEnd: number } {\n const nowMs = now.getTime();\n const endOfToday = utcDayStart(nowMs) + DAY_MS;\n const requestedEnd = parseRangeParam(toParam, 'to') ?? endOfToday;\n const windowEnd = Math.min(requestedEnd, endOfToday);\n const lastIncludedDay = utcDayStart(windowEnd - 1);\n const defaultStart = lastIncludedDay - (DEFAULT_METRICS_WINDOW - 1) * DAY_MS;\n const parsedFrom = parseRangeParam(fromParam, 'from');\n let windowStart = parsedFrom !== undefined && parsedFrom < windowEnd ? parsedFrom : defaultStart;\n const earliestStart = lastIncludedDay - (MAX_METRICS_WINDOW - 1) * DAY_MS;\n if (windowStart < earliestStart) windowStart = earliestStart;\n return { windowStart, windowEnd };\n}\n\n/** Stage history is server-appended, so an unparsable stamp is a corrupt row. */\nfunction parseTime(iso: string): number {\n const time = Date.parse(iso);\n if (Number.isNaN(time)) throw new Error(`Unparsable stage-history timestamp: ${iso}`);\n return time;\n}\n\n/** Asserted up front so a corrupt row fails every window, not just the ones that read it. */\nfunction assertParsableHistory(items: WorkItemRow[]): void {\n for (const item of items) {\n for (const entry of item.stageHistory) {\n parseTime(entry.enteredAt);\n if (entry.exitedAt !== undefined) parseTime(entry.exitedAt);\n }\n }\n}\n\n/** Nearest-rank percentile over an unsorted sample list. */\nfunction percentile(samples: number[], fraction: number): number | null {\n if (samples.length === 0) return null;\n const sorted = [...samples].sort((a, b) => a - b);\n const rank = Math.max(1, Math.ceil(fraction * sorted.length));\n return sorted[rank - 1]!;\n}\n\n/** UTC `YYYY-MM-DD` for a timestamp. */\nfunction utcDay(time: number): string {\n return new Date(time).toISOString().slice(0, 10);\n}\n\n/** Stages the item was holding at `time`, replayed from its history. */\nfunction stagesHeldAt(item: WorkItemRow, time: number): Set<string> {\n const held = new Set<string>();\n for (const entry of item.stageHistory) {\n if (parseTime(entry.enteredAt) >= time) continue;\n if (entry.exitedAt !== undefined && parseTime(entry.exitedAt) <= time) continue;\n held.add(entry.stage);\n }\n return held;\n}\n\ntype Window = { windowStart: number; windowEnd: number };\n\n/**\n * Completions per UTC day, plus one lead-time sample each. A completion is an\n * entry *into* `done`, not the state of the card now: a card reopened today\n * must not erase the day it shipped, and a card that shipped twice shipped\n * twice. Days before the oldest card are left out rather than gap-filled with\n * zeroes that would drag the daily average down.\n */\nfunction completions(items: WorkItemRow[], { windowStart, windowEnd }: Window) {\n let earliestItem = Infinity;\n for (const item of items) earliestItem = Math.min(earliestItem, item.createdAt.getTime());\n const boardStart = Number.isFinite(earliestItem) ? utcDayStart(earliestItem) : -Infinity;\n\n const byDay = new Map<string, number>();\n for (let day = Math.max(utcDayStart(windowStart), boardStart); day < windowEnd; day += DAY_MS) {\n byDay.set(utcDay(day), 0);\n }\n\n const leadSamples: number[] = [];\n for (const item of items) {\n for (const entry of item.stageHistory) {\n if (entry.stage !== DONE_STAGE) continue;\n const doneAt = parseTime(entry.enteredAt);\n if (doneAt < windowStart || doneAt >= windowEnd) continue;\n byDay.set(utcDay(doneAt), (byDay.get(utcDay(doneAt)) ?? 0) + 1);\n leadSamples.push(Math.max(0, doneAt - item.createdAt.getTime()));\n }\n }\n return { byDay, leadSamples };\n}\n\n/** Cards holding at least one pipeline stage right now — window-independent. */\nfunction countInFlight(items: WorkItemRow[]): number {\n return items.filter(item => item.stages.some(isPipelineStage)).length;\n}\n\n/** Where the window's cards came from, most common first. */\nfunction demandMix(items: WorkItemRow[], { windowStart, windowEnd }: Window): FactoryMetrics['sourceMix'] {\n const counts = new Map<string, number>();\n for (const item of items) {\n const created = item.createdAt.getTime();\n if (created < windowStart || created >= windowEnd) continue;\n const source = item.externalSource ? `${item.externalSource.integrationId}:${item.externalSource.type}` : 'manual';\n counts.set(source, (counts.get(source) ?? 0) + 1);\n }\n return [...counts.entries()].map(([source, count]) => ({ source, count })).sort((a, b) => b.count - a.count);\n}\n\n/**\n * How much of each stage's work an agent finished, counting a card's first\n * visit to a stage once. Rows appear in insertion order of each stage's first\n * counted exit; only pipeline stages get rows, since intake and terminal\n * stages have no pass to hand over.\n */\nfunction agentCoverage(items: WorkItemRow[], { windowStart, windowEnd }: Window): FactoryMetrics['agentCoverage'] {\n const byStage = new Map<string, FactoryMetrics['agentCoverage'][number]>();\n for (const item of items) {\n const heldAtWindowEnd = stagesHeldAt(item, windowEnd);\n const visited = new Set<string>();\n for (let i = 0; i < item.stageHistory.length; i++) {\n const entry = item.stageHistory[i]!;\n if (!isPipelineStage(entry.stage) || visited.has(entry.stage)) continue;\n visited.add(entry.stage);\n if (entry.exitedAt === undefined) continue;\n const exited = parseTime(entry.exitedAt);\n if (exited < windowStart || exited >= windowEnd) continue;\n\n let row = byStage.get(entry.stage);\n if (!row) {\n row = {\n stage: entry.stage,\n passes: 0,\n byAgent: 0,\n outcomes: { done: 0, canceled: 0, reworked: 0, inFlight: 0 },\n };\n byStage.set(entry.stage, row);\n }\n row.passes += 1;\n if (!isAgentActor(entry.exitedBy)) continue;\n row.byAgent += 1;\n\n const reworked = item.stageHistory.some(\n (later, j) => j > i && later.stage === entry.stage && parseTime(later.enteredAt) < windowEnd,\n );\n if (reworked) row.outcomes.reworked += 1;\n else if (heldAtWindowEnd.has(DONE_STAGE)) row.outcomes.done += 1;\n else if (heldAtWindowEnd.has(CANCELED_STAGE)) row.outcomes.canceled += 1;\n else row.outcomes.inFlight += 1;\n }\n }\n return [...byStage.values()];\n}\n\nexport function computeFactoryMetrics(boardItems: WorkItemRow[], window: Window): FactoryMetrics {\n const items = boardItems.filter(hasFactoryRun);\n assertParsableHistory(items);\n\n const { byDay, leadSamples } = completions(items, window);\n\n return {\n daysCovered: byDay.size,\n throughput: [...byDay.entries()]\n .map(([date, count]) => ({ date, count }))\n .sort((a, b) => a.date.localeCompare(b.date)),\n leadTime: {\n medianMs: percentile(leadSamples, 0.5),\n p90Ms: percentile(leadSamples, 0.9),\n samples: leadSamples.length,\n },\n wipTotal: countInFlight(items),\n sourceMix: demandMix(items, window),\n agentCoverage: agentCoverage(items, window),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"metrics.js","names":[],"sources":["../../../../src/storage/domains/work-items/metrics.ts"],"sourcesContent":["/**\n * Aggregation math for the Factory Overview page.\n *\n * Pure functions over `work_items` rows — throughput, lead time, in-flight\n * count, demand mix and per-stage agent coverage, all read from the\n * server-appended `stageHistory` log. Keeping this DB-free makes the math unit\n * testable and lets the route stay a thin shell.\n *\n * Everything windowed is counted as an event that happened inside the window,\n * never as \"the state the board happens to be in now\", so re-querying a past\n * window always returns the same numbers.\n */\n\nimport { isAgentActor } from './base.js';\nimport type { WorkItemRow } from './base.js';\n\n/** Default window span (days) when the request omits or malforms the range. */\nexport const DEFAULT_METRICS_WINDOW = 30;\n/** Hard cap on the range span (days) — bounds the gap-filled throughput array. */\nexport const MAX_METRICS_WINDOW = 366;\n\nconst DAY_MS = 86_400_000;\n\n/** Terminal stage — items here count as completed, not in-flight. */\nconst DONE_STAGE = 'done';\n\n/** Terminal stage for tracked non-completions — never a completion. */\nconst CANCELED_STAGE = 'canceled';\n\n/**\n * Terminal stages — items holding only these are not in-flight. `done` is a\n * completion (feeds throughput/lead time); `canceled` is a tracked\n * non-completion outcome and feeds neither.\n */\nconst TERMINAL_STAGES = new Set([DONE_STAGE, CANCELED_STAGE]);\n\nconst INTAKE_STAGE = 'intake';\n\n/**\n * Pipeline work excludes the inbox as well as the terminal stages: an intake\n * card is queued, not in flight, and its pass through is the poller filing it.\n */\nfunction isPipelineStage(stage: string): boolean {\n return !TERMINAL_STAGES.has(stage) && stage !== INTAKE_STAGE;\n}\n\n/**\n * Cards the Factory ran: starting a run records its session on the row. The\n * integrations sync every issue and PR of a connected repo into the board and\n * those outnumber the Factory's own work by an order of magnitude, so counting\n * them reports the upstream repo's flow as the Factory's.\n */\nfunction hasFactoryRun(item: WorkItemRow): boolean {\n return Object.keys(item.sessions).length > 0;\n}\n\n/**\n * Flow metrics over the cards the Factory ran ({@link hasFactoryRun}) — synced\n * upstream issues and PRs nobody started a run on are not the Factory's work\n * and are excluded from every field below.\n */\nexport interface FactoryMetrics {\n /**\n * Days the series covers: the requested window clipped to the board's life.\n * Days before the first card could hold no completion, so counting them would\n * drag the per-day rate toward zero on a young board.\n */\n daysCovered: number;\n /** Entries into `done` per UTC day, gap-filled across the covered days. */\n throughput: { date: string; count: number }[];\n /** Card creation → `done` for every completion that landed in the window. */\n leadTime: { medianMs: number | null; p90Ms: number | null; samples: number };\n /** Distinct cards in a pipeline stage — past intake, not yet terminal. */\n wipTotal: number;\n /** Cards created in the window, by source. */\n sourceMix: { source: string; count: number }[];\n /** Per-stage agent coverage over first visits that ended in the window. */\n agentCoverage: {\n stage: string;\n /**\n * First visits to this stage that ended in the window. Repeat visits are\n * excluded from both sides: they are rework, already reported as such, and\n * counting them in the denominator alone caps a fully agent-run stage below\n * 100%.\n */\n passes: number;\n /**\n * Of those: passes an agent finished (`exitedBy` is an agent actor). The\n * entry actor is not required — the rules engine is what queues a card into\n * a stage, so demanding both ends would report 0% for stages agents run\n * end to end. Missing `exitedBy` (entries written before exit stamping)\n * does not count.\n */\n byAgent: number;\n /**\n * Outcomes of the agent-finished passes' items as of the window's end,\n * mutually exclusive, first match wins: `reworked` (a later visit to the\n * same stage — deliberately outranks `done`: a pass that needed a redo is a\n * failed pass even if the item eventually merged), then `done`, then\n * `canceled`, then `inFlight`.\n */\n outcomes: { done: number; canceled: number; reworked: number; inFlight: number };\n }[];\n}\n\nconst DATE_ONLY_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n/** Datetime carrying an explicit `Z` or `±HH:MM` offset. */\nconst ZONED_DATETIME_RE = /(?:[Zz]|[+-]\\d{2}:?\\d{2})$/;\n\nfunction parseRangeParam(value: unknown, boundary: 'from' | 'to'): number | undefined {\n if (typeof value !== 'string' || value.length === 0) return undefined;\n const dateOnly = DATE_ONLY_RE.test(value);\n // Timezone-less datetimes are parsed as server-local by Date.parse, so the\n // window would shift by deployment region — reject them as invalid.\n if (!dateOnly && !ZONED_DATETIME_RE.test(value)) return undefined;\n const time = Date.parse(value);\n if (Number.isNaN(time)) return undefined;\n return boundary === 'to' && dateOnly ? time + DAY_MS : time;\n}\n\nfunction utcDayStart(time: number): number {\n return Date.parse(`${utcDay(time)}T00:00:00Z`);\n}\n\n/**\n * Resolve untrusted `from`/`to` into a bounded half-open UTC window. A date-only\n * `to` covers the whole day; an open/future end resolves to the end of the\n * current UTC day (not `now`) so an event at this instant stays inside the\n * window instead of on its excluded edge.\n */\nexport function parseMetricsRange(\n fromParam: unknown,\n toParam: unknown,\n now: Date,\n): { windowStart: number; windowEnd: number } {\n const nowMs = now.getTime();\n const endOfToday = utcDayStart(nowMs) + DAY_MS;\n const requestedEnd = parseRangeParam(toParam, 'to') ?? endOfToday;\n const windowEnd = Math.min(requestedEnd, endOfToday);\n const lastIncludedDay = utcDayStart(windowEnd - 1);\n const defaultStart = lastIncludedDay - (DEFAULT_METRICS_WINDOW - 1) * DAY_MS;\n const parsedFrom = parseRangeParam(fromParam, 'from');\n let windowStart = parsedFrom !== undefined && parsedFrom < windowEnd ? parsedFrom : defaultStart;\n const earliestStart = lastIncludedDay - (MAX_METRICS_WINDOW - 1) * DAY_MS;\n if (windowStart < earliestStart) windowStart = earliestStart;\n return { windowStart, windowEnd };\n}\n\n/** Stage history is server-appended, so an unparsable stamp is a corrupt row. */\nfunction parseTime(iso: string): number {\n const time = Date.parse(iso);\n if (Number.isNaN(time)) throw new Error(`Unparsable stage-history timestamp: ${iso}`);\n return time;\n}\n\n/** Asserted up front so a corrupt row fails every window, not just the ones that read it. */\nfunction assertParsableHistory(items: WorkItemRow[]): void {\n for (const item of items) {\n for (const entry of item.stageHistory) {\n parseTime(entry.enteredAt);\n if (entry.exitedAt !== undefined) parseTime(entry.exitedAt);\n }\n }\n}\n\n/** Nearest-rank percentile over an unsorted sample list. */\nfunction percentile(samples: number[], fraction: number): number | null {\n if (samples.length === 0) return null;\n const sorted = [...samples].sort((a, b) => a - b);\n const rank = Math.max(1, Math.ceil(fraction * sorted.length));\n return sorted[rank - 1]!;\n}\n\n/** UTC `YYYY-MM-DD` for a timestamp. */\nfunction utcDay(time: number): string {\n return new Date(time).toISOString().slice(0, 10);\n}\n\n/** Stages the item was holding at `time`, replayed from its history. */\nfunction stagesHeldAt(item: WorkItemRow, time: number): Set<string> {\n const held = new Set<string>();\n for (const entry of item.stageHistory) {\n if (parseTime(entry.enteredAt) >= time) continue;\n if (entry.exitedAt !== undefined && parseTime(entry.exitedAt) <= time) continue;\n held.add(entry.stage);\n }\n return held;\n}\n\ntype Window = { windowStart: number; windowEnd: number };\n\n/**\n * Completions per UTC day, plus one lead-time sample each. A completion is an\n * entry *into* `done`, not the state of the card now: a card reopened today\n * must not erase the day it shipped, and a card that shipped twice shipped\n * twice. Days before the oldest card are left out rather than gap-filled with\n * zeroes that would drag the daily average down.\n */\nfunction completions(items: WorkItemRow[], { windowStart, windowEnd }: Window) {\n let earliestItem = Infinity;\n for (const item of items) earliestItem = Math.min(earliestItem, item.createdAt.getTime());\n const boardStart = Number.isFinite(earliestItem) ? utcDayStart(earliestItem) : -Infinity;\n\n const byDay = new Map<string, number>();\n for (let day = Math.max(utcDayStart(windowStart), boardStart); day < windowEnd; day += DAY_MS) {\n byDay.set(utcDay(day), 0);\n }\n\n const leadSamples: number[] = [];\n for (const item of items) {\n for (const entry of item.stageHistory) {\n if (entry.stage !== DONE_STAGE) continue;\n const doneAt = parseTime(entry.enteredAt);\n if (doneAt < windowStart || doneAt >= windowEnd) continue;\n byDay.set(utcDay(doneAt), (byDay.get(utcDay(doneAt)) ?? 0) + 1);\n leadSamples.push(Math.max(0, doneAt - item.createdAt.getTime()));\n }\n }\n return { byDay, leadSamples };\n}\n\n/** Cards holding at least one pipeline stage right now — window-independent. */\nfunction countInFlight(items: WorkItemRow[]): number {\n return items.filter(item => item.stages.some(isPipelineStage)).length;\n}\n\n/** Where the window's cards came from, most common first. */\nfunction demandMix(items: WorkItemRow[], { windowStart, windowEnd }: Window): FactoryMetrics['sourceMix'] {\n const counts = new Map<string, number>();\n for (const item of items) {\n const created = item.createdAt.getTime();\n if (created < windowStart || created >= windowEnd) continue;\n const source = item.externalSource ? `${item.externalSource.integrationId}:${item.externalSource.type}` : 'manual';\n counts.set(source, (counts.get(source) ?? 0) + 1);\n }\n return [...counts.entries()].map(([source, count]) => ({ source, count })).sort((a, b) => b.count - a.count);\n}\n\n/**\n * How much of each stage's work an agent finished, counting a card's first\n * visit to a stage once. Rows appear in insertion order of each stage's first\n * counted exit; only pipeline stages get rows, since intake and terminal\n * stages have no pass to hand over.\n */\nfunction agentCoverage(items: WorkItemRow[], { windowStart, windowEnd }: Window): FactoryMetrics['agentCoverage'] {\n const byStage = new Map<string, FactoryMetrics['agentCoverage'][number]>();\n for (const item of items) {\n const heldAtWindowEnd = stagesHeldAt(item, windowEnd);\n const visited = new Set<string>();\n for (let i = 0; i < item.stageHistory.length; i++) {\n const entry = item.stageHistory[i]!;\n if (!isPipelineStage(entry.stage) || visited.has(entry.stage)) continue;\n visited.add(entry.stage);\n if (entry.exitedAt === undefined) continue;\n const exited = parseTime(entry.exitedAt);\n if (exited < windowStart || exited >= windowEnd) continue;\n\n let row = byStage.get(entry.stage);\n if (!row) {\n row = {\n stage: entry.stage,\n passes: 0,\n byAgent: 0,\n outcomes: { done: 0, canceled: 0, reworked: 0, inFlight: 0 },\n };\n byStage.set(entry.stage, row);\n }\n row.passes += 1;\n if (!isAgentActor(entry.exitedBy)) continue;\n row.byAgent += 1;\n\n const reworked = item.stageHistory.some(\n (later, j) => j > i && later.stage === entry.stage && parseTime(later.enteredAt) < windowEnd,\n );\n if (reworked) row.outcomes.reworked += 1;\n else if (heldAtWindowEnd.has(DONE_STAGE)) row.outcomes.done += 1;\n else if (heldAtWindowEnd.has(CANCELED_STAGE)) row.outcomes.canceled += 1;\n else row.outcomes.inFlight += 1;\n }\n }\n return [...byStage.values()];\n}\n\nexport function computeFactoryMetrics(boardItems: WorkItemRow[], window: Window): FactoryMetrics {\n const items = boardItems.filter(hasFactoryRun);\n assertParsableHistory(items);\n\n const { byDay, leadSamples } = completions(items, window);\n\n return {\n daysCovered: byDay.size,\n throughput: [...byDay.entries()]\n .map(([date, count]) => ({ date, count }))\n .sort((a, b) => a.date.localeCompare(b.date)),\n leadTime: {\n medianMs: percentile(leadSamples, 0.5),\n p90Ms: percentile(leadSamples, 0.9),\n samples: leadSamples.length,\n },\n wipTotal: countInFlight(items),\n sourceMix: demandMix(items, window),\n agentCoverage: agentCoverage(items, window),\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"messaging-tools.d.ts","sourceRoot":"","sources":["../../src/tools/messaging-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAA4C,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAIjH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAChF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAExE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAM9E,OAAO,KAAK,EAAE,yBAAyB,EAAyB,MAAM,cAAc,CAAC;AAIrF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGnD,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,qBAAqB,QAAiB,CAAC;AAKpD,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,yBAAyB,CAAC;IACtC,aAAa,EAAE,IAAI,CAAC,oBAAoB,EAAE,mBAAmB,GAAG,iBAAiB,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,EAAE,IAAI,CAAC,0BAA0B,EAAE,gBAAgB,GAAG,cAAc,GAAG,gBAAgB,CAAC,CAAC;IACjG,SAAS,EAAE,IAAI,CACb,gBAAgB,EACd,gCAAgC,GAChC,eAAe,GACf,yBAAyB,GACzB,0BAA0B,GAC1B,iBAAiB,GACjB,yBAAyB,CAC5B,CAAC;IACF,aAAa,EAAE,aAAa,CAAC;IAC7B,mHAAmH;IACnH,SAAS,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACnD;AAgBD,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,gCAAgC,GACrC,gBAAgB,CA2XlB"}
1
+ {"version":3,"file":"messaging-tools.d.ts","sourceRoot":"","sources":["../../src/tools/messaging-tools.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAGV,oBAAoB,EAErB,MAAM,4BAA4B,CAAC;AAIpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAChF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAExE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAM9E,OAAO,KAAK,EAAE,yBAAyB,EAAyB,MAAM,cAAc,CAAC;AAIrF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGnD,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,qBAAqB,QAAiB,CAAC;AAyCpD,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,yBAAyB,CAAC;IACtC,aAAa,EAAE,IAAI,CAAC,oBAAoB,EAAE,mBAAmB,GAAG,iBAAiB,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,EAAE,IAAI,CAAC,0BAA0B,EAAE,gBAAgB,GAAG,cAAc,GAAG,gBAAgB,CAAC,CAAC;IACjG,SAAS,EAAE,IAAI,CACb,gBAAgB,EACd,gCAAgC,GAChC,eAAe,GACf,yBAAyB,GACzB,0BAA0B,GAC1B,iBAAiB,GACjB,yBAAyB,CAC5B,CAAC;IACF,aAAa,EAAE,aAAa,CAAC;IAC7B,mHAAmH;IACnH,SAAS,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACnD;AAgBD,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,gCAAgC,GACrC,gBAAgB,CAohBlB"}
@@ -8,10 +8,30 @@ import { createTool } from "@mastra/core/tools";
8
8
  //#region src/tools/messaging-tools.ts
9
9
  /** Messages plus replies, per sender session, per target thread, per window. */
10
10
  const SIGNAL_RATE_LIMIT = 10;
11
- const SIGNAL_RATE_WINDOW_MS = 600 * 1e3;
11
+ const SIGNAL_RATE_WINDOW_MS = 6e5;
12
12
  /** How many recent `factory-session` notifications on a thread the rate and duplicate checks read. */
13
13
  const RECENT_SCAN = 100;
14
- const RATE_LIMITED = "Too many messages to that session in the last 10 minutes; wait before sending more.";
14
+ /** How often a waiting queue is reconsidered. Delivery still waits for a free slot in the window. */
15
+ const QUEUE_FLUSH_MS = 1e3;
16
+ /**
17
+ * Waiting messages, per sender session and target thread, oldest first.
18
+ * Process-local: a restart drops what was still waiting, the same as a message
19
+ * that had not been handed to the tool yet. Delivered messages live in the
20
+ * notification store and are unaffected.
21
+ */
22
+ const pendingQueues = /* @__PURE__ */ new Map();
23
+ const flushTimers = /* @__PURE__ */ new Map();
24
+ const queueOwners = /* @__PURE__ */ new WeakMap();
25
+ let nextQueueOwnerId = 0;
26
+ const queueKey = (owner, senderSessionId, threadId) => {
27
+ let id = queueOwners.get(owner);
28
+ if (id === void 0) {
29
+ nextQueueOwnerId += 1;
30
+ id = nextQueueOwnerId;
31
+ queueOwners.set(owner, id);
32
+ }
33
+ return `${id}\0${senderSessionId}\0${threadId}`;
34
+ };
15
35
  const message = z.string().trim().min(1).max(4e3);
16
36
  function senderAttributes(scope) {
17
37
  return {
@@ -53,16 +73,146 @@ function createFactoryMessagingTools(scope, deps) {
53
73
  if (row.visibility === "private" && row.userId !== scope.userId) return "private";
54
74
  return null;
55
75
  };
56
- /** Messages, replies and reports (not progress) this session put on a thread inside the rate window. */
57
- const overRateLimit = (records) => {
76
+ const overRateLimit = (records) => countedInWindow(records).length >= 10;
77
+ /** This session's delivered messages on the thread that still sit inside the window, oldest first. */
78
+ const countedInWindow = (records) => {
58
79
  const since = Date.now() - SIGNAL_RATE_WINDOW_MS;
59
- return records.filter((record) => record.kind !== "progress" && record.kind !== "stage" && record.attributes?.["from-session"] === scope.sessionId && record.createdAt.getTime() >= since).length >= 10;
80
+ return records.filter((record) => record.kind !== "progress" && record.kind !== "stage" && record.attributes?.["from-session"] === scope.sessionId && record.createdAt.getTime() >= since).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
81
+ };
82
+ /**
83
+ * When the next message can be delivered: one window after the oldest message
84
+ * that has to age out to free a slot, given how many are already waiting.
85
+ */
86
+ const nextDeliveryAt = (counted, waiting) => {
87
+ const blocking = counted[counted.length + waiting - 10];
88
+ return (blocking ? blocking.createdAt.getTime() : Date.now()) + SIGNAL_RATE_WINDOW_MS;
60
89
  };
90
+ const queuedResult = (deliverAt) => ({
91
+ delivered: "queued",
92
+ deliverAt: new Date(deliverAt).toISOString(),
93
+ note: `Queued; it will be delivered at about ${new Date(deliverAt).toISOString()}. Do not send it again.`
94
+ });
61
95
  const recent = (notifications, threadId) => notifications.listNotifications({
62
96
  threadId,
63
97
  source: FACTORY_SESSION_NOTIFICATION_SOURCE,
64
98
  limit: RECENT_SCAN
65
99
  });
100
+ const actor = () => ({
101
+ actorId: human ? scope.userId : `agent:${scope.threadId}`,
102
+ actorType: human ? "human" : "agent"
103
+ });
104
+ /**
105
+ * Deliver now, or hold until the window has a slot. The cap limits how fast
106
+ * messages are delivered, not whether they are accepted.
107
+ */
108
+ const accept = async (messaging, target, notification) => {
109
+ const counted = countedInWindow(await recent(messaging.notifications, target.threadId));
110
+ if (counted.length < 10) {
111
+ const result = await deliverFactorySessionNotification({
112
+ controller: messaging.controller,
113
+ sessions: deps.sessions,
114
+ runs: deps.workItems,
115
+ target,
116
+ notification
117
+ });
118
+ if (result.delivered === "skipped") throw unavailable(result);
119
+ await audit({
120
+ targetSessionId: target.sessionId,
121
+ priority: notification.priority ?? "medium",
122
+ kind: notification.kind,
123
+ delivery: result.delivered
124
+ });
125
+ return {
126
+ notificationId: result.notificationId,
127
+ delivered: result.delivered
128
+ };
129
+ }
130
+ const key = queueKey(deps, scope.sessionId, target.threadId);
131
+ const waiting = pendingQueues.get(key)?.length ?? 0;
132
+ const deliverAt = nextDeliveryAt(counted, waiting);
133
+ enqueue(key, {
134
+ notification,
135
+ acceptedAt: Date.now(),
136
+ actor: actor(),
137
+ deliverAt,
138
+ target
139
+ });
140
+ return queuedResult(deliverAt);
141
+ };
142
+ const enqueue = (key, delivery) => {
143
+ const queue = pendingQueues.get(key) ?? [];
144
+ queue.push(delivery);
145
+ queue.sort((a, b) => a.acceptedAt - b.acceptedAt);
146
+ pendingQueues.set(key, queue);
147
+ scheduleFlush(key);
148
+ };
149
+ /**
150
+ * Deliver the oldest waiting message once the window has a free slot, then
151
+ * the next, in the order they were accepted. A target that is gone by then
152
+ * is dropped and recorded, the same way an undeliverable live message is.
153
+ */
154
+ const flush = async (key) => {
155
+ flushTimers.delete(key);
156
+ const queue = pendingQueues.get(key);
157
+ if (!queue || queue.length === 0) {
158
+ pendingQueues.delete(key);
159
+ return;
160
+ }
161
+ const messaging = await deps.messaging();
162
+ if (!messaging) {
163
+ scheduleFlush(key);
164
+ return;
165
+ }
166
+ const head = queue[0];
167
+ if (!head || head.deliverAt > Date.now()) {
168
+ scheduleFlush(key);
169
+ return;
170
+ }
171
+ if (countedInWindow(await recent(messaging.notifications, head.target.threadId)).length >= 10) {
172
+ scheduleFlush(key);
173
+ return;
174
+ }
175
+ queue.shift();
176
+ if (queue.length === 0) pendingQueues.delete(key);
177
+ const result = await deliverFactorySessionNotification({
178
+ controller: messaging.controller,
179
+ sessions: deps.sessions,
180
+ runs: deps.workItems,
181
+ target: head.target,
182
+ notification: head.notification
183
+ });
184
+ await deps.auditRecorder.record({
185
+ orgId: scope.orgId,
186
+ actorId: head.actor.actorId,
187
+ actorType: head.actor.actorType,
188
+ action: "factory.agent.signaled",
189
+ factoryProjectId: scope.factoryProjectId,
190
+ targets: [{
191
+ type: "session",
192
+ id: head.target.sessionId
193
+ }],
194
+ metadata: {
195
+ targetSessionId: head.target.sessionId,
196
+ priority: head.notification.priority ?? "medium",
197
+ kind: head.notification.kind,
198
+ delivery: result.delivered === "skipped" ? "dropped" : result.delivered,
199
+ ...result.delivered === "skipped" ? { reason: result.reason } : {}
200
+ }
201
+ });
202
+ if (queue.length > 0) scheduleFlush(key);
203
+ };
204
+ function scheduleFlush(key) {
205
+ if (flushTimers.has(key)) return;
206
+ if (!pendingQueues.get(key)?.[0]) return;
207
+ const timer = setTimeout(() => {
208
+ flushTimers.delete(key);
209
+ flush(key).catch(() => {
210
+ if ((pendingQueues.get(key)?.length ?? 0) > 0) scheduleFlush(key);
211
+ });
212
+ }, QUEUE_FLUSH_MS);
213
+ timer.unref?.();
214
+ flushTimers.set(key, timer);
215
+ }
66
216
  /** On turns nobody signed in started, only sessions working on items this session follows. */
67
217
  const followedWorkers = async () => {
68
218
  const project = {
@@ -126,38 +276,20 @@ function createFactoryMessagingTools(scope, deps) {
126
276
  sessionId
127
277
  });
128
278
  if (!threadId) throw new Error("The session is not available (thread-not-found).");
129
- if (overRateLimit(await recent(messaging.notifications, threadId))) throw new Error(RATE_LIMITED);
130
- const result = await deliverFactorySessionNotification({
131
- controller: messaging.controller,
132
- sessions: deps.sessions,
133
- runs: deps.workItems,
134
- target: {
135
- sessionId,
136
- threadId
137
- },
138
- notification: {
139
- kind: "message",
140
- priority,
141
- summary: text,
142
- payload: { message: text },
143
- sourceId: scope.sessionId,
144
- attributes: {
145
- ...await workItemAttribute(sessionId, row.orgId, scope.factoryProjectId),
146
- ...senderAttributes(scope)
147
- }
148
- }
149
- });
150
- if (result.delivered === "skipped") throw unavailable(result);
151
- await audit({
152
- targetSessionId: sessionId,
153
- priority,
279
+ return accept(messaging, {
280
+ sessionId,
281
+ threadId
282
+ }, {
154
283
  kind: "message",
155
- delivery: result.delivered
284
+ priority,
285
+ summary: text,
286
+ payload: { message: text },
287
+ sourceId: scope.sessionId,
288
+ attributes: {
289
+ ...await workItemAttribute(sessionId, row.orgId, scope.factoryProjectId),
290
+ ...senderAttributes(scope)
291
+ }
156
292
  });
157
- return {
158
- notificationId: result.notificationId,
159
- delivered: result.delivered
160
- };
161
293
  }
162
294
  });
163
295
  const reply = createTool({
@@ -193,41 +325,22 @@ function createFactoryMessagingTools(scope, deps) {
193
325
  const blocked = unreachable(await deps.sessions.getBySessionId(fromSession));
194
326
  if (blocked === "not-found") throw new Error(`Message ${inReplyTo} didn't come from a session in this Factory.`);
195
327
  if (blocked === "private") throw new Error(`Message ${inReplyTo} came from a private session this session can't answer.`);
196
- const onSenderThread = await recent(messaging.notifications, fromThread);
197
- if (onSenderThread.some((record) => record.kind === "reply" && record.attributes?.["in-reply-to"] === inReplyTo && record.attributes?.["from-session"] === scope.sessionId)) throw new Error(`You already answered ${inReplyTo}.`);
198
- if (overRateLimit(onSenderThread)) throw new Error(RATE_LIMITED);
199
- const result = await deliverFactorySessionNotification({
200
- controller: messaging.controller,
201
- sessions: deps.sessions,
202
- runs: deps.workItems,
203
- target: {
204
- sessionId: fromSession,
205
- threadId: fromThread
206
- },
207
- notification: {
208
- kind: "reply",
209
- priority: "high",
210
- summary: text,
211
- payload: { message: text },
212
- sourceId: scope.sessionId,
213
- attributes: {
214
- ...senderAttributes(scope),
215
- "in-reply-to": inReplyTo,
216
- ...status ? { status } : {}
217
- }
218
- }
219
- });
220
- if (result.delivered === "skipped") throw unavailable(result);
221
- await audit({
222
- targetSessionId: fromSession,
223
- priority: "high",
328
+ if ((await recent(messaging.notifications, fromThread)).some((record) => record.kind === "reply" && record.attributes?.["in-reply-to"] === inReplyTo && record.attributes?.["from-session"] === scope.sessionId)) throw new Error(`You already answered ${inReplyTo}.`);
329
+ return accept(messaging, {
330
+ sessionId: fromSession,
331
+ threadId: fromThread
332
+ }, {
224
333
  kind: "reply",
225
- delivery: result.delivered
334
+ priority: "high",
335
+ summary: text,
336
+ payload: { message: text },
337
+ sourceId: scope.sessionId,
338
+ attributes: {
339
+ ...senderAttributes(scope),
340
+ "in-reply-to": inReplyTo,
341
+ ...status ? { status } : {}
342
+ }
226
343
  });
227
- return {
228
- notificationId: result.notificationId,
229
- delivered: result.delivered
230
- };
231
344
  }
232
345
  });
233
346
  /** A report with no message to answer: to the origin, else to the item's followers. */
@@ -280,10 +393,21 @@ function createFactoryMessagingTools(scope, deps) {
280
393
  const results = [];
281
394
  for (const recipient of recipients) {
282
395
  if (status !== "progress" && overRateLimit(await recent(messaging.notifications, recipient.threadId))) {
396
+ const counted = countedInWindow(await recent(messaging.notifications, recipient.threadId));
397
+ const key = queueKey(deps, scope.sessionId, recipient.threadId);
398
+ const waiting = pendingQueues.get(key)?.length ?? 0;
399
+ const at = nextDeliveryAt(counted, waiting);
400
+ enqueue(key, {
401
+ notification,
402
+ acceptedAt: Date.now(),
403
+ actor: actor(),
404
+ deliverAt: at,
405
+ target: recipient
406
+ });
283
407
  results.push({
284
408
  sessionId: recipient.sessionId,
285
- delivered: "skipped",
286
- reason: "rate-limited"
409
+ delivered: "queued",
410
+ deliverAt: new Date(at).toISOString()
287
411
  });
288
412
  continue;
289
413
  }
@@ -306,11 +430,15 @@ function createFactoryMessagingTools(scope, deps) {
306
430
  delivery: result.delivered
307
431
  });
308
432
  }
309
- const delivered = results.filter((result) => result.delivered !== "skipped").length;
310
- if (delivered === 0 && results.every((result) => result.reason === "rate-limited")) throw new Error(RATE_LIMITED);
433
+ const delivered = results.filter((result) => result.delivered !== "skipped" && result.delivered !== "queued").length;
434
+ const queued = results.filter((result) => result.delivered === "queued").length;
311
435
  return {
312
436
  delivered,
313
- recipients: results
437
+ recipients: results,
438
+ ...queued > 0 ? {
439
+ queued,
440
+ note: "Queued past the delivery cap. It will be delivered when the window allows. Do not send it again."
441
+ } : {}
314
442
  };
315
443
  }
316
444
  const listSide = createTool({