mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
fix: evaluate status cards on committed changes and deadlines (#18401)
Reviewed and verified at 21795e222a.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e40de0c9e8
commit
b6b57079b9
64 files changed
+2671
-225
No files matched your search
@@ -0,0 +1,192 @@
|
||||
# Status-card evaluation deadlines
|
||||
|
||||
Status-card evaluation uses existing cards, updates and issues as durable truth.
|
||||
`index.ts` registers a typed worker. The status-card owner controls evaluation,
|
||||
generation claims, assignment through the ordinary issue wake API, and failed
|
||||
assignment cancellation. Shared delivery infrastructure controls transaction
|
||||
settlement, coalescing, retries, admission drain, standby and shutdown.
|
||||
|
||||
Manual cards have no automatic obligation. Interval cards retain the requested
|
||||
evaluation cadence. Reactive cards evaluate committed source changes and retain
|
||||
only an actual debounce, hourly rate, daily budget, active-hour or saved-query
|
||||
membership deadline. Unchanged cards disarm. A changed experimental setting
|
||||
invalidates the worker's cached feature gate. Other source writes do not reread
|
||||
settings. Startup reconstructs every disarmed reactive scope together with any
|
||||
concurrent company-scoped hint, without moving saved eligibility/retry deadlines.
|
||||
|
||||
Company search reports the earliest matching `updatedWithin` expiry through an
|
||||
optional internal callback in its existing aggregate query. It includes rows
|
||||
outside the returned page: an expiring priority-ranked row ahead of an offset
|
||||
can change the watched set. The callback exposes only a normalized deadline;
|
||||
ordinary search consumers and API responses retain their existing contract.
|
||||
Overlapping query scopes can require an evaluation when one query expires even
|
||||
if another still retains the same issue; no generation occurs without a filtered
|
||||
card change. No recurring scan replaces the expired query obligation.
|
||||
|
||||
## Claims and recovery
|
||||
|
||||
A feature-owned PostgreSQL transaction advisory lock serializes scheduled
|
||||
per-card evaluation across connections. Its transaction holds no card row lock
|
||||
while existing issue/card services commit through their own transactions. The
|
||||
lock remains held through actual refresh settlement. Existing card deadline CAS,
|
||||
generating-issue ownership and issue idempotency keys remain in force. Manual
|
||||
refresh and older instances continue to use those existing guards. A busy lock
|
||||
retains a company-scoped retry; consuming a hint is not successful evaluation.
|
||||
A failed scheduled refresh retains the existing five-minute durable claim.
|
||||
|
||||
Once a generation exists, the normal issue assignment authority remains its
|
||||
owner. Assignment failure cancels that issue and finalizes the card's failed
|
||||
ledger entry. The user's ordinary refresh action can retry it. Restart recovery
|
||||
must not repeat an already completed generation. No new timer table, automatic
|
||||
write replay, execution-authority exemption or sleep-safety relaxation is added.
|
||||
|
||||
## Supported producer coverage
|
||||
|
||||
All hints are registered before the relevant write within its owning transaction.
|
||||
Nested service writes share outer commit ownership. Company-scoped hints batch
|
||||
reactive reconciliation; ordinary execution writes without known scope retain
|
||||
all existing execution consumers without sending an instance-wide card hint.
|
||||
|
||||
| Producer | Notification boundary and coverage |
|
||||
| --- | --- |
|
||||
| Card create, configuration, archive/restore, delete, compile, query/summary result, evaluation claim | Status-card mutation transactions; stalled issue finalization signals before releasing the card claim |
|
||||
| Experimental status-card setting | Instance experimental-settings transaction; full reconstruction is appropriate for this instance-wide setting |
|
||||
| Company pause/archive/reactivate/delete | Company lifecycle/deletion transactions and budget-owned company hard-stop/resume transactions |
|
||||
| Issue create/update/delete and label changes | Ordinary issue transactions; label deletion signals its known company before cascades; title and prefix changes signal their existing owner transactions |
|
||||
| Issue checkout/adoption/release/reset and environment-selection recency | Existing issue transaction, or a transaction around the previously standalone write; company comes from the existing issue/run read |
|
||||
| Comment create, edit, delete, tombstone, import and recency | Common comment insert; outer root comment transaction; queued-comment and Telegram edit transactions; existing metadata-upgrade path; remove/tombstone transactions |
|
||||
| Issue import, comment import, document import | One notification per affected company in the existing batch transaction; no per-row notification lookup |
|
||||
| Document create/update/restore/delete and search membership | Document owner transactions; only the existing company is passed, without copying search predicates into producers |
|
||||
| Review, interaction and execution transitions | Existing issue-review-state and native-status-reassessment boundaries; scoped execution-control helpers; interaction recency touches own a transaction when called after settlement |
|
||||
| Run admission, retries, monitors, watchdog/recovery and native settlement/reclaim | Existing scoped review/milestone/status/execution-control hooks; explicit notifications at the uncovered issue execution bind, legacy claim, monitor claim/release and review-participant recovery transaction |
|
||||
| Agent deletion/access reassignment and project deletion/privacy cascades | Existing company-scoped owner transactions before foreign-key or recursive issue changes |
|
||||
| Workspace selection/binding, plugin/provider workspace and cleanup/audit touches | Existing owner transactions, including standalone cleanup and audit recency writes now enclosed with their intent |
|
||||
| Skill-test harness expiry and task-watchdog fingerprint touch | Existing owner mutation transaction; no feature policy is moved into the writer |
|
||||
|
||||
The audit includes direct issue/comment mutations, raw recursive privacy updates,
|
||||
bulk insert helpers, label/project/agent cascades, linked document search content
|
||||
and timestamp-dependent search membership. Metadata-only writes that do not
|
||||
change the fingerprint, matching fields or visibility do not create an evaluation
|
||||
obligation: examples include run identity linkage, workspace IDs without a
|
||||
recency change, document locks and source-trust receipts without searched content
|
||||
changes. Routine and status-summary documents that have no issue-document link
|
||||
do not change issue search membership.
|
||||
|
||||
This follows the existing [single-active-writer contract](../idle-sleep-safety.md).
|
||||
Supported services, import/recovery writers, caller-owned transactions and startup
|
||||
reconstruction participate. Independent SQL writers or another active server
|
||||
process require explicit wake ingress; process-local hints do not establish
|
||||
cross-replica notification delivery. The advisory lock supplies competing
|
||||
scheduler exclusion, not cross-process event transport. Internal deadlines do
|
||||
not establish a Cloud wake alarm or prove host/database suspension.
|
||||
|
||||
## Verification
|
||||
|
||||
Real PostgreSQL acceptance uses ordinary card create/compile and authorized
|
||||
summarizer writeback. The assignment boundary is controlled: it persists a wake,
|
||||
and the test explicitly performs the authorized result write; no model runs.
|
||||
Checks cover empty/manual/reactive/disabled quiet SQL, first source change,
|
||||
debounce and durable wake/result, earlier/cancelled deadlines, outer commit and
|
||||
rollback, drain/resume, competing connections, busy locks, restart eligibility,
|
||||
active hours/rate/budget gates, multiple companies, budget-owned company resume,
|
||||
ignored activity, document/comment membership, relative query expiry including
|
||||
an off-page candidate, failed assignment cleanup/manual retry and the persisted
|
||||
five-minute internal-failure retry across restart/drain.
|
||||
|
||||
Shared coordinator regressions also cover warm standby, ambiguous transaction
|
||||
intent, failure entering drain and different domain/coordinator gates. The paused
|
||||
worker releases only settled holds and retains a future in-memory resume wake.
|
||||
An expired saved deadline cannot schedule an immediate skipped-attempt loop.
|
||||
Existing startup, status-card, company-search, execution and monitor suites cover
|
||||
the production wiring and unchanged consumers. Exact commands and results belong
|
||||
in the PR verification record; this document does not claim deployed savings.
|
||||
|
||||
### Review corrections: pool capacity and restart deadline ownership
|
||||
|
||||
Scheduled evaluation holds its advisory transaction on `withDedicatedDbConnection`,
|
||||
not on the ordinary write pool. The helper ends that physical connection in
|
||||
`finally`, including contention and failed evaluation. Its lock still spans the
|
||||
actual refresh settlement. Summary writeback runs watched-query reads on its
|
||||
existing transaction, so a one-connection main pool completes the full flow.
|
||||
|
||||
Migration0420 adds nullable internal `next_eval_reason` to the existing card row.
|
||||
Membership deadlines may be reconstructed at startup after a committed source
|
||||
hint was lost. Policy eligibility and the atomically recorded five-minute retry
|
||||
lease retain their saved deadline. Explicit refresh deadlines remain due work.
|
||||
The HTTP card response removes this internal provenance; the public schema stays
|
||||
unchanged.
|
||||
|
||||
Migration0419 retains the published agent-comment retry uniqueness and conversation read-state backfill. The status-card column follows it in migration0420; the combined schema snapshot is generated from both owners.
|
||||
|
||||
For legacy rows, a pending change hash preserves policy eligibility. An untagged
|
||||
active deadline farther than five minutes away cannot be an outstanding
|
||||
five-minute claim, so startup rechecks it. An ambiguous untagged deadline within
|
||||
five minutes keeps its remaining time without extension. This bounds migration
|
||||
compatibility delay while preserving prior crash/failure recovery. After the
|
||||
first successful evaluation all newly saved deadlines have explicit ownership.
|
||||
No recurring compatibility scan is added.
|
||||
|
||||
Source notifications use the shared deadline helper's fixed one-second batching
|
||||
window. The first hint after idle can reconcile immediately. Later hints merge
|
||||
by company until that window ends. The window does not slide with each new
|
||||
write, so a sustained source stream cannot postpone reconciliation forever.
|
||||
One second keeps reactive cards prompt (the removed heartbeat default was 30s)
|
||||
and bounds burst SQL. No minimum spacing is set: an already-known policy,
|
||||
expiry, or retry deadline can preempt batching. The window alone creates no
|
||||
scheduled work after settlement. Real issue/comment producer acceptance measures
|
||||
due/next queries and actual searches under 100 writes, preserves both companies'
|
||||
hints, dispatches another company's saved deadline inside the batching window,
|
||||
and checks SQL quietness after authorized results.
|
||||
|
||||
Card deletion resolves the card's owning company and emits only that company's
|
||||
deadline-reconstruction hint. Every card mutation requires a company ID; only
|
||||
instance-level settings retain wildcard ingress. Deleting an already-missing
|
||||
card emits no hint. The real two-company regression proves deletion does not
|
||||
re-run unchanged reactive searches or wake either summarizer, then stays quiet.
|
||||
|
||||
Active-hours eligibility keeps the existing exact UTC-minute search across DST
|
||||
changes. A bounded 32-entry timezone formatter cache removes repeated ICU
|
||||
construction. A bounded 256-entry cache reuses the next opening for the exact
|
||||
(timezone,start,end) policy only while the candidate instant stays within its
|
||||
previously searched closed range. The precise instant is checked first, so
|
||||
historical second-based offsets, the opening itself, and millisecond deadlines
|
||||
keep their behavior. Clock rollback and policy changes cannot reuse the wrong
|
||||
range. Empty windows return without formatter or search work. A cold nonempty
|
||||
policy can still perform at most 2881 conversions including its first check; warm
|
||||
closed-range evaluations perform one conversion, not another minute walk.
|
||||
Both caches use bounded LRU retention and hold no company/card data. Tests prove
|
||||
eviction, skipped/repeated hours, half-hour DST, date-line changes, rollback,
|
||||
strict boundaries, and actual twenty-card source delivery followed by quiet.
|
||||
|
||||
|
||||
Refresh decisions use the card's PostgreSQL row revision as a private compare-and-swap fence. If a card changes during watched-source reads, the owner rereads it and reconsiders the current policy, for at most three attempts. A replacement future interval deadline remains unchanged; a replacement manual policy does not run automatically. The same fence protects no-change, paused-policy and generation claims. Refresh task idempotency includes that captured revision: concurrent requests for the same revision still share one task, while a later revision gets an independent task even when its watched fingerprint is unchanged. A superseded refresh can therefore cancel its losing task without cancelling a newer revision's not-yet-claimed task. Compile task identity is unchanged. The row revision is not exposed through card APIs or shared scheduling contracts.
|
||||
|
||||
Company source hints do not preempt a future deadline owned by a failed evaluation (`retry`). Membership and policy deadlines remain eligible for source-driven rechecks, and actual due retries still run. An explicit policy edit can replace the retry through the existing card mutation path. Real PostgreSQL regressions hold the actual search while committing an ordinary policy edit, then verify the saved interval still fires. Persistent search failures stay quiet through issue hints and restart, retry at their saved deadline, and recover through an ordinary policy edit.
|
||||
|
||||
Card queries request the search owner's internal `issueResultsOnly` capability.
|
||||
Nonissue result branches and their counts are excluded before pagination, so
|
||||
project, agent or artifact changes cannot evict a watched issue from a bounded
|
||||
page. This corrects the former accidental competition with results that cards
|
||||
discarded after pagination. The saved query's text scope is unchanged: `all`
|
||||
still matches issues through their comments and documents, and explicit comments
|
||||
or documents scopes retain their task matches. Issue filters, privacy, tenant
|
||||
boundaries, facets and membership-expiry deadlines remain search-owned. Card
|
||||
preview, visibility counts, generation and writeback all use the same restriction.
|
||||
Normal company search keeps its mixed result kinds by default. No extra
|
||||
project/agent/artifact wake fanout or public query syntax is introduced.
|
||||
|
||||
A losing refresh cancels only an unclaimed task. It locks the exact company task
|
||||
in the canonical issue-first order, checks its generation ledger, and preserves
|
||||
terminal state and timestamps. Successful summary writeback records that ledger
|
||||
in the same transaction that clears the card's generation pointer. Because the
|
||||
failed card revision CAS excludes a future claim of this revision's task, the
|
||||
loser cannot mistake a completed shared winner for an unclaimed orphan. The same
|
||||
original request returns without enqueueing or reporting an active generation;
|
||||
a separate later manual request can still start a new revision. Canonical issue
|
||||
cancellation uses a transaction-bound service so its preflight reads also work
|
||||
with one connection. Activity publications and native-question actions run only
|
||||
after the outer transaction commits. Archive and truly unclaimed-task cleanup
|
||||
retain their existing effects.
|
||||
|
||||
|
||||
Every reactive summary writeback records one immediate card-owned refresh deadline in the same transaction that clears its generation. This preserves changes whose committed source hint was consumed while the old generation still owned the card, including changes after the summary's final watched-source read. The existing card commit hint restores that one deadline; it does not fan out to other companies or force generation. A fresh unchanged search disarms it. A relevant diff follows the existing policy, assignment, result and quiet path. Manual and interval writeback cadence stays unchanged. Rollback removes both the summary and its recheck obligation. Real PostgreSQL controls hold the actual summary search, commit an ordinary issue write, observe the worker consume that hint while generation is still owned, then verify exactly one needed generation or one unchanged recheck followed by SQL quiet.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "status_cards" ADD COLUMN "next_eval_reason" text;
|
||||
+299
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dce41524-29a3-4552-ba5d-f0abefbf1ea1",
|
||||
"prevId": "27c9008c-60e3-449b-9630-9e4977fad444",
|
||||
"id": "8f75a1bb-32d9-4aed-9f93-134a6ae71f79",
|
||||
"prevId": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -19052,6 +19052,98 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_mutation_receipts": {
|
||||
"name": "company_skill_mutation_receipts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"skill_id": {
|
||||
"name": "skill_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"response": {
|
||||
"name": "response",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skill_mutation_receipts_key_idx": {
|
||||
"name": "company_skill_mutation_receipts_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skill_mutation_receipts_company_id_companies_id_fk": {
|
||||
"name": "company_skill_mutation_receipts_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skill_mutation_receipts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_repository_snapshots": {
|
||||
"name": "company_skill_repository_snapshots",
|
||||
"schema": "",
|
||||
@@ -34070,7 +34162,7 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_invalid_metadata', 'restore_failed')) IS NOT TRUE",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
@@ -37013,6 +37105,15 @@
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_agent_client_request_uq": {
|
||||
"name": "issue_comments_agent_client_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_agent_id",
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_company_id_uq": {
|
||||
"name": "issue_comments_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
@@ -51482,6 +51583,195 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sandbox_creation_operations": {
|
||||
"name": "sandbox_creation_operations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"environment_id": {
|
||||
"name": "environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_environment_id": {
|
||||
"name": "original_environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"request_key": {
|
||||
"name": "request_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'run'"
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"execution_workspace_id": {
|
||||
"name": "execution_workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"plugin_id": {
|
||||
"name": "plugin_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"driver_key": {
|
||||
"name": "driver_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lease_id": {
|
||||
"name": "lease_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_attempt_at": {
|
||||
"name": "next_attempt_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_creation_operations_pending_deadline_idx": {
|
||||
"name": "sandbox_creation_operations_pending_deadline_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "next_attempt_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"sandbox_creation_operations\".\"state\" = 'pending'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sandbox_creation_operations_owner_idx": {
|
||||
"name": "sandbox_creation_operations_owner_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "original_environment_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "request_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sandbox_creation_operations_environment_id_environments_id_fk": {
|
||||
"name": "sandbox_creation_operations_environment_id_environments_id_fk",
|
||||
"tableFrom": "sandbox_creation_operations",
|
||||
"tableTo": "environments",
|
||||
"columnsFrom": [
|
||||
"environment_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
@@ -52489,6 +52779,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
+7
@@ -2927,6 +2927,13 @@
|
||||
"when": 1793400383797,
|
||||
"tag": "0419_slippery_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 420,
|
||||
"version": "7",
|
||||
"when": 1793453735600,
|
||||
"tag": "0420_broad_johnny_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,6 +56,9 @@ export const statusCards = pgTable(
|
||||
lastModel: text("last_model"),
|
||||
generatingIssueId: uuid("generating_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
failureReason: text("failure_reason"),
|
||||
// Internal deadline ownership; null denotes a row written before provenance
|
||||
// was recorded. Membership may be rechecked at startup, retry/policy may not.
|
||||
nextEvalReason: text("next_eval_reason").$type<"membership" | "policy" | "retry" | "refresh">(),
|
||||
nextEvalAt: timestamp("next_eval_at", { withTimezone: true }),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
archivedByUserId: text("archived_by_user_id"),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const adminUrl = process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
|
||||
const support = adminUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const journal = JSON.parse(await readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")) as {
|
||||
entries: Array<{ idx: number; tag: string }>;
|
||||
};
|
||||
const master = journal.entries.find(entry => entry.tag === "0419_slippery_impossible_man")!;
|
||||
const status = journal.entries.find(entry => entry.tag === "0420_broad_johnny_storm")!;
|
||||
const statusSql = await readFile(new URL(`./migrations/${status.tag}.sql`, import.meta.url), "utf8");
|
||||
const hash = async (tag: string) => createHash("sha256")
|
||||
.update(await readFile(new URL(`./migrations/${tag}.sql`, import.meta.url), "utf8")).digest("hex");
|
||||
|
||||
async function database() {
|
||||
if (!adminUrl) return startEmbeddedPostgresTestDatabase("status-reason-upgrade-");
|
||||
const name = `status_reason_${randomUUID().replaceAll("-", "")}_test`;
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`create database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
const url = new URL(adminUrl); url.pathname = `/${name}`;
|
||||
const cleanup = async () => {
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`drop database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
};
|
||||
try { await applyPendingMigrations(url.toString()); }
|
||||
catch (error) { await cleanup(); throw error; }
|
||||
return { connectionString: url.toString(), cleanup };
|
||||
}
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
const lastMessage = new Date("2026-10-20T12:00:00.000Z");
|
||||
await sql`insert into companies (id, name, issue_prefix) values (${companyId}, 'Migration proof', ${`M${companyId.slice(0, 10)}`})`;
|
||||
await sql`insert into agents (id, company_id, name, adapter_type) values (${agentId}, ${companyId}, 'Agent', 'process')`;
|
||||
await sql`insert into "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values (${userId}, 'Reader', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`insert into issues (id, company_id, title, status, assignee_agent_id,
|
||||
conversation_agent_id, conversation_user_id, conversation_state)
|
||||
values (${issueId}, ${companyId}, 'Conversation', 'in_review', ${agentId}, ${agentId}, ${userId}, 'waiting')`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, client_request_id, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'agent', 'Preserved reply', 'comment', 'retry-key', ${lastMessage})`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'system', 'Excluded system notice', 'comment', ${new Date(lastMessage.getTime() + 1000)})`;
|
||||
const cardId = randomUUID(), savedDeadline = new Date("2026-10-21T12:00:00.000Z");
|
||||
await sql`insert into status_cards (id, company_id, interest_prompt, refresh_policy, next_eval_at, pending_change_hash)
|
||||
values (${cardId}, ${companyId}, 'Watch preserved work', '{"mode":"manual"}'::jsonb, ${savedDeadline}, 'preserved-change')`;
|
||||
return { companyId, agentId, issueId, userId, lastMessage, cardId, savedDeadline };
|
||||
}
|
||||
|
||||
async function assertFinal(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>, expectedRead: Date) {
|
||||
const [column] = await sql`select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'status_cards' and column_name = 'next_eval_reason'`;
|
||||
expect(column).toMatchObject({ data_type: "text", is_nullable: "YES" });
|
||||
const [constraint] = await sql`select i.indisvalid, i.indisunique from pg_constraint c
|
||||
join pg_index i on i.indexrelid = c.conindid
|
||||
where c.conrelid = 'issue_comments'::regclass and c.conname = 'issue_comments_agent_client_request_uq'`;
|
||||
expect(constraint).toMatchObject({ indisvalid: true, indisunique: true });
|
||||
const [read] = await sql`select last_read_at from issue_read_states
|
||||
where company_id = ${row.companyId} and issue_id = ${row.issueId} and user_id = ${row.userId}`;
|
||||
expect(read.last_read_at).toEqual(expectedRead);
|
||||
const [card] = await sql`select next_eval_at, next_eval_reason, pending_change_hash from status_cards where id = ${row.cardId}`;
|
||||
expect(card).toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: null, pending_change_hash: "preserved-change" });
|
||||
await sql`update status_cards set next_eval_reason = 'retry' where id = ${row.cardId}`;
|
||||
expect((await sql`select next_eval_at, next_eval_reason from status_cards where id = ${row.cardId}`)[0])
|
||||
.toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: "retry" });
|
||||
await expect(sql`insert into issue_comments (company_id, issue_id, author_agent_id, body, client_request_id)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.agentId}, 'Duplicate', 'retry-key')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_agent_client_request_uq" });
|
||||
}
|
||||
|
||||
describe.skipIf(!support.supported)("status-card reason migration after the published comment migration", () => {
|
||||
it.each(["0418", "master0419"] as const)("upgrades from %s without dropping comment uniqueness or read history", async previous => {
|
||||
expect(master.idx).toBe(419);
|
||||
expect(status.idx).toBeGreaterThan(master.idx);
|
||||
expect(statusSql.trim()).toBe('ALTER TABLE "status_cards" ADD COLUMN "next_eval_reason" text;');
|
||||
const db = await database();
|
||||
const sql = postgres(db.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
// Reconstruct exactly the two migrations' preimages; all older schema and
|
||||
// receipts stay intact, as in the repository's other replay regressions.
|
||||
await sql`alter table status_cards drop column next_eval_reason`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(status.tag)}`;
|
||||
if (previous === "0418") {
|
||||
await sql`alter table issue_comments drop constraint issue_comments_agent_client_request_uq`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(master.tag)}`;
|
||||
}
|
||||
const row = await seed(sql);
|
||||
const preservedRead = new Date("2026-10-19T12:00:00.000Z");
|
||||
if (previous === "master0419") await sql`insert into issue_read_states (company_id, issue_id, user_id, last_read_at)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.userId}, ${preservedRead})`;
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: previous === "0418" ? [`${master.tag}.sql`, `${status.tag}.sql`] : [`${status.tag}.sql`],
|
||||
});
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({ status: "upToDate" });
|
||||
await assertFinal(sql, row, previous === "0418" ? row.lastMessage : preservedRead);
|
||||
const receipts = await sql`select hash from drizzle.__drizzle_migrations order by id`;
|
||||
const hashes = receipts.map(receipt => receipt.hash);
|
||||
expect(hashes.indexOf(await hash(master.tag))).toBeLessThan(hashes.indexOf(await hash(status.tag)));
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await sql`select hash from drizzle.__drizzle_migrations order by id`).toEqual(receipts);
|
||||
} finally { await sql.end(); await db.cleanup(); }
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -16341,9 +16341,15 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await tx.update(toolConnections).set({ credentialSecretRefs: connection.credentialSecretRefs.map(ref => ({ ...ref,
|
||||
versionSelector: ref.versionSelector === "1" ? "latest" : "1" })) }).where(eq(toolConnections.id, connection.id));
|
||||
});
|
||||
await expect.poll(async () => ((await f.checkpoint()).value as Record<string, unknown>).credentialFingerprint,
|
||||
{ timeout: 5000 }).not.toBe(oldWindow.credentialFingerprint);
|
||||
expect((await f.checkpoint()).value).toMatchObject({ generation: oldWindow.generation, outcome: "epoch_initialized" });
|
||||
// Credential synchronization and epoch initialization commit separately.
|
||||
// Require the final epoch receipt, not just the intermediate fingerprint.
|
||||
await expect.poll(async () => {
|
||||
const value = (await f.checkpoint()).value as Record<string, unknown>;
|
||||
return { ...value, fingerprintChanged: typeof value.credentialFingerprint === "string"
|
||||
&& value.credentialFingerprint !== oldWindow.credentialFingerprint };
|
||||
}, { timeout: 5000 }).toMatchObject({
|
||||
fingerprintChanged: true, generation: oldWindow.generation, outcome: "epoch_initialized",
|
||||
});
|
||||
expect(f.requests).toEqual([]);
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issueLabels,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
labels,
|
||||
projectAccessMembers,
|
||||
@@ -28,12 +30,13 @@ import {
|
||||
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_COMPANY_SEARCH_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
`Skipping embedded Postgres company search tests on this host: ${("reason" in embeddedPostgresSupport ? embeddedPostgresSupport.reason : "unsupported environment")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,8 +83,11 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
if (!externalDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
||||
db = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
// External suites can reuse this dedicated database sequentially. Match
|
||||
// the fresh embedded fixture before this suite creates its own rows.
|
||||
if (externalDatabaseUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
|
||||
svc = companySearchService(db);
|
||||
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
||||
}, 20_000);
|
||||
@@ -100,6 +106,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
@@ -168,6 +175,87 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
it("paginates issue-result-only searches before unrelated entity competition", async () => {
|
||||
const companyId = await createCompany();
|
||||
const now = Date.now();
|
||||
const older = await createIssue(companyId, { title: "Launch older", updatedAt: new Date(now - 2_000) });
|
||||
await createIssue(companyId, { title: "Launch newer", updatedAt: new Date(now - 1_000) });
|
||||
await createAgent(companyId, { name: "Launch agent", updatedAt: new Date(now) });
|
||||
await createProject(companyId, { name: "Launch project", updatedAt: new Date(now + 1_000) });
|
||||
const holder = await createIssue(companyId, { title: "Artifact holder" });
|
||||
await db.insert(issueWorkProducts).values({ companyId, issueId: holder, type: "artifact", provider: "paperclip",
|
||||
title: "Launch artifact", status: "ready", updatedAt: new Date(now + 2_000) });
|
||||
const query = companySearchQuerySchema.parse({ q: "launch", scope: "all", sort: "updated", limit: 1, offset: 1 });
|
||||
const mixed = await svc.search(companyId, { ...query, limit: 10, offset: 0 });
|
||||
expect(new Set(mixed.results.map(row => row.type))).toEqual(new Set(["issue", "agent", "project", "artifact"]));
|
||||
const result = await svc.search(companyId, query, { issueResultsOnly: true });
|
||||
expect(result.results.map(row => row.id)).toEqual([older]);
|
||||
expect(result.countsByType).toMatchObject({ issue: 2, agent: 0, project: 0, artifact: 0 });
|
||||
expect(result.filterOptionCounts.status.todo).toBe(2);
|
||||
expect(result.hasMore).toBe(false);
|
||||
for (const scope of ["agents", "projects", "artifacts"] as const) {
|
||||
let membership: Date | null | undefined;
|
||||
const excluded = await svc.search(companyId, { ...query, scope }, {
|
||||
issueResultsOnly: true, onNextMembershipChange: at => { membership = at; },
|
||||
});
|
||||
expect(excluded.results).toEqual([]);
|
||||
expect(Object.values(excluded.countsByType).every(count => count === 0)).toBe(true);
|
||||
expect(excluded.filterOptionCounts.status).toEqual({});
|
||||
expect(excluded.hasMore).toBe(false);
|
||||
expect(membership).toBeNull();
|
||||
}
|
||||
let deadline: Date | null = null;
|
||||
await svc.search(companyId, { ...query, updatedWithin: "1h" }, {
|
||||
issueResultsOnly: true, onNextMembershipChange: at => { deadline = at; },
|
||||
});
|
||||
expect(deadline).toEqual(new Date(now - 2_000 + 3_600_001));
|
||||
});
|
||||
|
||||
it.each(["all", "comments", "documents"] as const)("preserves issue-result-only %s text matching and read boundaries", async scope => {
|
||||
const previous = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const comment = await createIssue(companyId, { title: "Comment context" });
|
||||
const document = await createIssue(companyId, { title: "Document context" });
|
||||
const privateId = randomUUID();
|
||||
await createIssue(companyId, { id: privateId, title: "Private context", visibility: "private",
|
||||
privacyRootIssueId: privateId, responsibleUserId: "private-owner" });
|
||||
const foreign = await createIssue(await createCompany(), { title: "Foreign context" });
|
||||
const hidden = await createIssue(companyId, { title: "Hidden context", hiddenAt: new Date() });
|
||||
for (const issueId of [comment, privateId, hidden]) await db.insert(issueComments).values({ companyId, issueId, body: "Needle context" });
|
||||
const foreignCompany = await db.select({ companyId: issues.companyId }).from(issues).where(sql`${issues.id} = ${foreign}`);
|
||||
await db.insert(issueComments).values({ companyId: foreignCompany[0]!.companyId, issueId: foreign, body: "Needle context" });
|
||||
for (const issueId of [document, privateId, hidden]) {
|
||||
const id = randomUUID();
|
||||
await db.insert(documents).values({ id, companyId, title: "Context", latestBody: "Needle context", format: "markdown" });
|
||||
await db.insert(issueDocuments).values({ companyId, issueId, documentId: id, key: "brief" });
|
||||
}
|
||||
const condition = await issueReadSqlCondition(db, { type: "board", userId: "reader", source: "session" });
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", scope }), {
|
||||
issueResultsOnly: true, issueReadCondition: condition,
|
||||
});
|
||||
expect(new Set(result.results.map(row => row.id))).toEqual(new Set(scope === "all" ? [comment, document] : [scope === "comments" ? comment : document]));
|
||||
expect(result.results.every(row => row.type === "issue")).toBe(true);
|
||||
expect(result.countsByType).toMatchObject({ agent: 0, project: 0, artifact: 0 });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps issue-result-only loosen counts inside the selected result kinds", async () => {
|
||||
const companyId = await createCompany();
|
||||
await createIssue(companyId, { title: "Launch done", status: "done" });
|
||||
await createAgent(companyId, { name: "Launch agent" });
|
||||
await createProject(companyId, { name: "Launch project" });
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", status: "todo" }), { issueResultsOnly: true });
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.zeroResults).toMatchObject({ unfilteredTotal: 1,
|
||||
loosenSuggestions: [{ filter: "status", resultCount: 1, additionalCount: 1 }] });
|
||||
expect(result.filterOptionCounts.status.done).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
|
||||
const companyId = await createCompany();
|
||||
const agentId = await createAgent(companyId, { name: "Mibile" });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import * as runState from "../services/heartbeat/run-state.js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -262,6 +264,14 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const [delivered] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
|
||||
expect(delivered.runId).toBeTruthy();
|
||||
// The receipt can commit before execution enters liveRunExecutions.
|
||||
// Observe actual adapter admission before awaiting its drain.
|
||||
await vi.waitFor(async () => {
|
||||
const current = await heartbeat.getRun(delivered.runId!);
|
||||
expect(countExecuteCallsForRun(delivered.runId!), JSON.stringify({
|
||||
status: current?.status, errorCode: current?.errorCode,
|
||||
})).toBe(1);
|
||||
}, { timeout: 5_000 });
|
||||
await heartbeat.waitForRunExecutionDrain(delivered.runId!);
|
||||
expect(countExecuteCallsForRun(delivered.runId!)).toBe(1);
|
||||
expect((await heartbeat.getRun(delivered.runId!))?.contextSnapshot).toMatchObject({ wakeCommentIds: [comment.id] });
|
||||
@@ -286,8 +296,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
await db.update(agents).set({ status: "idle", lifecycleState: "ready" }).where(eq(agents.id, agentId));
|
||||
const recovered = heartbeatService(db, { runtimeEnv: { ...process.env, PAPERCLIP_IN_WORKTREE: "false" } });
|
||||
await recovered.resumeQueuedRuns();
|
||||
expect(await waitForCondition(async () => countExecuteCallsForRun(run!.id) === 1)).toBe(true);
|
||||
await recovered.waitForRunExecutionDrain(run!.id);
|
||||
await recovered.drainActiveRunExecutions();
|
||||
expect(countExecuteCallsForRun(run!.id)).toBe(1);
|
||||
});
|
||||
|
||||
@@ -371,6 +380,47 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("joins a claimed execution whose initial run read has not reached live registration", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const [issue] = await db.insert(issues).values({ companyId, title: "Held preparation", status: "todo", assigneeAgentId: agentId }).returning();
|
||||
const queued = await seedQueuedRun({ companyId, agentId, issueId: issue.id, wakeReason: "issue_assigned" });
|
||||
const entered = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const factory = runState.createHeartbeatRunState;
|
||||
let held = false;
|
||||
const spy = vi.spyOn(runState, "createHeartbeatRunState").mockImplementation((...args) => {
|
||||
const owner = factory(...args);
|
||||
const read = owner.getRun;
|
||||
return { ...owner, getRun: async (...readArgs: Parameters<typeof read>) => {
|
||||
const row = await read(...readArgs);
|
||||
if (!held && row?.id === queued.runId && row.status === "running") {
|
||||
held = true; entered.resolve(); await release.promise;
|
||||
}
|
||||
return row;
|
||||
} };
|
||||
});
|
||||
const recovered = heartbeatService(db, { runtimeEnv: { ...process.env, PAPERCLIP_IN_WORKTREE: "false" } });
|
||||
try {
|
||||
await recovered.resumeQueuedRuns();
|
||||
await entered.promise;
|
||||
// Receipt admission is earlier than live-run registration. The legacy
|
||||
// run-specific drain can return here; the tracked execution must not.
|
||||
await recovered.waitForRunExecutionDrain(queued.runId);
|
||||
expect(countExecuteCallsForRun(queued.runId)).toBe(0);
|
||||
let settled = false;
|
||||
const draining = recovered.drainActiveRunExecutions().then(() => { settled = true; });
|
||||
await immediate();
|
||||
expect(settled).toBe(false);
|
||||
release.resolve();
|
||||
await draining;
|
||||
expect(countExecuteCallsForRun(queued.runId)).toBe(1);
|
||||
expect(await recovered.getRun(queued.runId)).toMatchObject({ status: "succeeded", runtimeMode: "legacy" });
|
||||
} finally {
|
||||
release.resolve();
|
||||
await recovered.drainActiveRunExecutions();
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "assignments", sameIssue: true, first: "assignment", second: "assignment" },
|
||||
{ name: "unrelated tasks", sameIssue: false, first: "assignment", second: "assignment" },
|
||||
@@ -402,13 +452,18 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
await queue(secondIssueId, second);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const expectedExecutions = sameIssue ? 1 : 2;
|
||||
let executions = 0;
|
||||
mockAdapterExecute.mockImplementation(async () => {
|
||||
if (++executions === expectedExecutions) entered.resolve();
|
||||
await gate;
|
||||
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null, summary: "Assignment finished.", provider: "test", model: "test-model" };
|
||||
});
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
expect(await waitForCondition(() => Promise.resolve(mockAdapterExecute.mock.calls.length > 0))).toBe(true);
|
||||
await entered.promise;
|
||||
expect(mockAdapterExecute).toHaveBeenCalledTimes(expectedExecutions);
|
||||
const runs = await db.select().from(heartbeatRuns);
|
||||
const running = runs.filter((run) => run.status === "running");
|
||||
expect(running).toHaveLength(sameIssue ? 1 : 2);
|
||||
@@ -833,19 +888,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
};
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForCondition(async () => {
|
||||
const [run, wakeup] = await Promise.all([
|
||||
db.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
return run?.status === "cancelled" && wakeup?.status === "skipped";
|
||||
});
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
const [run, wakeup, issue] = await Promise.all([
|
||||
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
@@ -1047,7 +1090,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
});
|
||||
|
||||
expect(run).not.toBeNull();
|
||||
await waitForCondition(async () => countExecuteCallsForRun(run!.id) > 0);
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
expect(countExecuteCallsForRun(run!.id)).toBe(1);
|
||||
});
|
||||
@@ -1326,14 +1369,14 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
});
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual" });
|
||||
expect(run).not.toBeNull();
|
||||
expect(await waitForCondition(() => Promise.resolve(countExecuteCallsForRun(run!.id) === 1))).toBe(true);
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect(countExecuteCallsForRun(run!.id)).toBe(1);
|
||||
const [context] = mockAdapterExecute.mock.calls.find(([context]) => context?.runId === run!.id)!;
|
||||
expect(context.runtime.sessionId).toBe(rotate ? null : sessionId);
|
||||
if (rotate) {
|
||||
expect(context.context.paperclipSessionRotationReason).toContain((inputTokens + (rawInputIncludesCached === false ? cachedInputTokens : 0)).toLocaleString("en-US"));
|
||||
expect(context.context.paperclipSessionHandoffMarkdown).toContain("Continue from this completed turn");
|
||||
} else expect(context.context.paperclipSessionRotationReason).toBeUndefined();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const [finished] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id));
|
||||
expect(finished.usageJson).toMatchObject({ rawInputTokens: 1, rawCachedInputTokens: 2, rawInputIncludesCached: false });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { statusCardEvaluationService, registerStatusCardEvaluationWork } from "../services/status-card-evaluation.js";
|
||||
import { nativeHarnessBackupExpirationService } from "../services/native-runtime/native-harness-backup-expiration.js";
|
||||
import { skillVersionRetentionService } from "../services/skill-version-retention.js";
|
||||
import { approvalQueueSeedingService } from "../services/approval-queue-seeding-work.js";
|
||||
@@ -530,6 +531,13 @@ vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
|
||||
validateNativeQuestionResponseInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/status-card-evaluation.js", () => ({
|
||||
statusCardEvaluationService: vi.fn(() => ({ run: vi.fn(async () => {}), next: vi.fn(async () => null), changed: vi.fn() })),
|
||||
registerStatusCardEvaluationWork: vi.fn((_coordinator, service, canRun) => ({
|
||||
ready: canRun() ? service.run(new AbortController().signal) : Promise.resolve(), wake: vi.fn(), stop: vi.fn(async () => {}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../services/secret-proposals.js", () => ({
|
||||
createSecretProposalsService: vi.fn(() => ({
|
||||
expiryDeadlines: vi.fn(async () => []),
|
||||
@@ -793,6 +801,28 @@ describe("startServer feedback export wiring", () => {
|
||||
expect(deliveryWorkErrors.filter(error => error.queue === DELIVERY_QUEUES.skillVersionRetention)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])("registers status-card evaluation only with maintenance and never ticks it from the heartbeat loop (%s)", async enabled => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled, heartbeatSchedulerIntervalMs: 1000 }));
|
||||
let tick: (() => void) | undefined;
|
||||
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, interval: number) => {
|
||||
if (interval === 1000) tick = callback;
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval);
|
||||
try {
|
||||
await startServer(); await settleStartupHousekeeping();
|
||||
expect(registerStatusCardEvaluationWork).toHaveBeenCalledTimes(enabled ? 1 : 0);
|
||||
expect(statusCardEvaluationService).toHaveBeenCalledTimes(enabled ? 1 : 0);
|
||||
if (enabled) {
|
||||
const service = vi.mocked(statusCardEvaluationService).mock.results.at(-1)!.value;
|
||||
expect(service.run).toHaveBeenCalledOnce();
|
||||
expect(tick).toBeDefined();
|
||||
for (let i = 0; i < 3; i++) { tick!(); await settleStartupHousekeeping(); }
|
||||
expect(service.run).toHaveBeenCalledOnce();
|
||||
}
|
||||
expect(deliveryWorkErrors.filter(error => error.queue === DELIVERY_QUEUES.statusCardEvaluation)).toEqual([]);
|
||||
} finally { timer.mockRestore(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("registers response recovery and its shutdown subscription only when maintenance is enabled (%s)", async enabled => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
|
||||
await startServer(); await settleStartupHousekeeping();
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import * as companySearch from "../services/company-search.js";
|
||||
import { setImmediate as immediate } from "node:timers/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agentWakeupRequests, agents, approvals, budgetIncidents, budgetPolicies, costEvents, companies, companyMemberships, createDb, documents, withDedicatedDbConnection,
|
||||
heartbeatRuns, instanceSettings, issueComments, issueWorkProducts, issues, projects, statusCards, statusCardUpdates, documentRevisions } from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, statusCardRefreshPolicySchema } from "@paperclipai/shared";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerStatusCardEvaluationWork, statusCardEvaluationService } from "../services/status-card-evaluation.js";
|
||||
import { statusCardService } from "../services/status-cards.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_STATUS_CARD_EVALUATION_TEST_DATABASE_URL");
|
||||
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("status-card evaluation work", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
let draining = false;
|
||||
let companyId: string, agentId: string;
|
||||
const errors: unknown[] = [];
|
||||
const evaluations = new Set<Promise<unknown>>();
|
||||
let compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"] });
|
||||
const actor = { agentId: null, userId: "board-user" };
|
||||
const policy = (mode: "manual" | "reactive" | "interval" = "reactive") => statusCardRefreshPolicySchema.parse({
|
||||
mode, debounceSeconds: 60, intervalMinutes: 15,
|
||||
});
|
||||
// Controlled provider boundary: assignment persists a queued wake, and tests
|
||||
// explicitly run the authorized summarizer writeback. No model/provider runs.
|
||||
const wakeup = vi.fn(async (id: string, options: { payload?: Record<string, unknown> | null; reason?: string | null }) => {
|
||||
const [agent] = await db.select({ companyId: agents.companyId }).from(agents).where(eq(agents.id, id));
|
||||
return db.insert(agentWakeupRequests).values({ companyId: agent!.companyId, agentId: id, source: "assignment",
|
||||
reason: options.reason, payload: options.payload, status: "queued" }).returning();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!external) temporary = await startEmbeddedPostgresTestDatabase("status-card-evaluation-");
|
||||
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
||||
}, 30_000);
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID(); agentId = randomUUID(); draining = false; errors.length = 0; wakeup.mockReset();
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"] });
|
||||
await db.insert(companies).values({ id: companyId, name: "Status", issuePrefix: `S${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
|
||||
await instanceSettingsService(db).updateExperimental({ enableStatusCards: true });
|
||||
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await coordinator?.stop(); coordinator = undefined;
|
||||
vi.useRealTimers(); vi.restoreAllMocks();
|
||||
await db.delete(budgetIncidents); await db.delete(budgetPolicies); await db.delete(approvals); await db.delete(costEvents);
|
||||
await db.delete(agentWakeupRequests); await db.delete(statusCardUpdates); await db.delete(statusCards);
|
||||
await db.delete(documentRevisions); await db.delete(documents); await db.delete(activityLog);
|
||||
await db.delete(issueComments); await db.delete(issues); await db.delete(heartbeatRuns);
|
||||
await db.delete(instanceSettings); await db.delete(projects); await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies);
|
||||
});
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
async function start(service = statusCardEvaluationService(db, { wakeup })) {
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true,
|
||||
onError: error => errors.push(error) });
|
||||
await registerStatusCardEvaluationWork(coordinator, { ...service, run: signal => {
|
||||
const work = service.run(signal);
|
||||
evaluations.add(work);
|
||||
void work.then(() => evaluations.delete(work), () => evaluations.delete(work));
|
||||
return work;
|
||||
} }, () => !draining).ready;
|
||||
}
|
||||
async function settled() {
|
||||
// Do not leap across PostgreSQL connect timers while real I/O is pending.
|
||||
// Await actual domain work, then require several settled event-loop turns
|
||||
// so a transient zero hold cannot stand in for transaction reconciliation.
|
||||
for (let turn = 0; turn < 3; turn++) {
|
||||
await vi.waitFor(async () => {
|
||||
await Promise.allSettled([...evaluations]);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.allSettled([...evaluations]);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 5000 });
|
||||
await immediate();
|
||||
}
|
||||
}
|
||||
async function quiet() {
|
||||
await settled();
|
||||
expect(coordinator!.nextWakeAt()).toBeNull();
|
||||
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "update"), executes = vi.spyOn(db, "execute");
|
||||
const calls = wakeup.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); expect(executes).not.toHaveBeenCalled();
|
||||
expect(wakeup).toHaveBeenCalledTimes(calls);
|
||||
reads.mockRestore(); writes.mockRestore(); executes.mockRestore();
|
||||
}
|
||||
async function writeback(cardId: string, generationIssueId: string, compile = false) {
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ checkoutRunId: run!.id }).where(eq(issues.id, generationIssueId));
|
||||
const writer = { agentId, runId: run!.id };
|
||||
const cards = statusCardService(db);
|
||||
if (compile) await cards.writeQuery(cardId, { queries: [compiledQuery],
|
||||
title: "Blockers", changeSummary: "Compiled", generationIssueId }, writer);
|
||||
await cards.writeSummary(cardId, { markdown: "Current blockers", changeSummary: "Updated", generationIssueId }, writer);
|
||||
await issueService(db).update(generationIssueId, { status: "done" });
|
||||
}
|
||||
async function card(mode: "manual" | "reactive" | "interval" = "reactive", refreshPolicy = policy(mode)) {
|
||||
const cards = statusCardService(db);
|
||||
const created = await cards.create(companyId, { interestPrompt: "Blockers", titlePinned: false, agentId, refreshPolicy }, actor);
|
||||
const compile = await cards.requestCompile(created.id, actor);
|
||||
await writeback(created.id, compile.generatingIssue.id, true);
|
||||
if (!coordinator && mode === "reactive") await cards.tickDueStatusCards();
|
||||
return cards.getById(created.id).then(row => row!);
|
||||
}
|
||||
|
||||
it("settles empty, disabled, configured manual and unchanged reactive history without recurring SQL", async () => {
|
||||
await start(); await quiet();
|
||||
await card("manual"); await card("reactive"); await quiet();
|
||||
await instanceSettingsService(db).updateExperimental({ enableStatusCards: false }); await quiet();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps mixed-search card preview, startup and ordinary issue delivery on issue-only pages", async () => {
|
||||
const target = await issueService(db).create(companyId, { title: "Launch blocker", status: "blocked", priority: "medium" });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const project = await projectService(db).create(companyId, { name: "Launch project" });
|
||||
await db.insert(agents).values({ companyId, name: "Launch agent", role: "general", status: "idle", adapterType: "process" });
|
||||
const holder = await issueService(db).create(companyId, { title: "Artifact holder", status: "todo", priority: "medium" });
|
||||
await db.insert(issueWorkProducts).values({ companyId, issueId: holder.id, type: "artifact", provider: "paperclip",
|
||||
title: "Launch artifact", status: "ready" });
|
||||
compiledQuery = companySearchQuerySchema.parse({ q: "launch", scope: "all", sort: "updated", limit: 1 });
|
||||
const created = await card();
|
||||
expect(Object.keys(created.fingerprint!)).toEqual([target.id]);
|
||||
const cards = statusCardService(db);
|
||||
const [preview] = await cards.dryRun(created);
|
||||
expect(preview!.result.results.map(row => row.id)).toEqual([target.id]);
|
||||
await start(); await quiet();
|
||||
await issueService(db).update(target.id, { status: "done" });
|
||||
await settled();
|
||||
const pending = (await cards.getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(Math.max(0, pending.nextEvalAt!.getTime() - Date.now()));
|
||||
await settled();
|
||||
const generating = (await cards.getById(created.id))!;
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
await writeback(created.id, generating.generatingIssueId!); await quiet();
|
||||
await projectService(db).update(project.id, { name: "Launch project renamed" });
|
||||
const updated = (await cards.getById(created.id))!;
|
||||
expect((await cards.dryRun(updated))[0]!.result.results.map(row => row.id)).toEqual([target.id]);
|
||||
expect(updated.fingerprint![target.id]!.status).toBe("done");
|
||||
await quiet();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("ordinary issue change schedules debounce, assigns one durable wake, writes a result and becomes quiet", async () => {
|
||||
await start(); const created = await card(); await quiet();
|
||||
const changed = await issueService(db).create(companyId, { title: "New blocker", status: "blocked", priority: "medium", createdByUserId: "board-user" });
|
||||
await vi.waitFor(async () => {
|
||||
expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0);
|
||||
}, { timeout: 5000 });
|
||||
const pending = await statusCardService(db).getById(created.id);
|
||||
expect(pending?.pendingChangeCount).toBeGreaterThan(0);
|
||||
expect(pending?.nextEvalAt).toBeInstanceOf(Date);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(60_000); await settled();
|
||||
const generating = await statusCardService(db).getById(created.id);
|
||||
expect(generating?.generatingIssueId).toBeTruthy();
|
||||
const durable = await db.select().from(agentWakeupRequests);
|
||||
expect(durable).toHaveLength(1); expect(durable[0]?.payload?.issueId).toBe(generating?.generatingIssueId);
|
||||
expect(changed.companyId).toBe(companyId);
|
||||
await writeback(created.id, generating!.generatingIssueId!); await quiet();
|
||||
expect(await db.select().from(statusCardUpdates).then(rows => rows.filter(row => row.generationIssueId === generating?.generatingIssueId)))
|
||||
.toMatchObject([{ status: "ok", finishedAt: expect.any(Date) }]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])("rechecks source hints consumed during summary writeback without forcing unchanged generation (relevant=%s)", async relevant => {
|
||||
const evaluation = statusCardEvaluationService(db, { wakeup });
|
||||
const runs = vi.spyOn(evaluation, "run"), hints = vi.spyOn(evaluation, "changed");
|
||||
await start(evaluation);
|
||||
const created = await card();
|
||||
await quiet();
|
||||
const cards = statusCardService(db);
|
||||
const generation = await cards.requestRefresh(created.id, { trigger: "manual", actor });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ checkoutRunId: run!.id }).where(eq(issues.id, generation.generatingIssue!.id));
|
||||
const writer = { agentId, runId: run!.id };
|
||||
await settled();
|
||||
const entered = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const factory = companySearch.companySearchService;
|
||||
let hold = true;
|
||||
const searchedCompanies: string[] = [];
|
||||
vi.spyOn(companySearch, "companySearchService").mockImplementation(owner => {
|
||||
const search = factory(owner), actual = search.search;
|
||||
search.search = async (...args) => {
|
||||
searchedCompanies.push(args[0]);
|
||||
const result = await actual(...args);
|
||||
if (hold) { hold = false; entered.resolve(); await release.promise; }
|
||||
return result;
|
||||
};
|
||||
return search;
|
||||
});
|
||||
const writing = cards.writeSummary(created.id, { markdown: "Current blockers", changeSummary: "Completed before source change", generationIssueId: generation.generatingIssue!.id }, writer);
|
||||
await entered.promise;
|
||||
const passes = runs.mock.calls.length;
|
||||
hints.mockClear();
|
||||
try {
|
||||
await issueService(db).create(companyId, { title: "Source committed after summary search", status: relevant ? "blocked" : "backlog", priority: "medium" });
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.allSettled([...evaluations]);
|
||||
expect(hints.mock.calls.some(([scope]) => scope?.has(`changes:${companyId}`))).toBe(true);
|
||||
expect(runs.mock.calls.length).toBeGreaterThan(passes);
|
||||
}, { timeout: 5000 });
|
||||
expect((await cards.getById(created.id))!.generatingIssueId).toBe(generation.generatingIssue!.id);
|
||||
expect(await evaluation.next()).toBeNull();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect(searchedCompanies).toEqual([companyId]);
|
||||
} finally { release.resolve(); }
|
||||
await writing;
|
||||
await settled();
|
||||
if (relevant) {
|
||||
const pending = (await cards.getById(created.id))!;
|
||||
expect(pending.generatingIssueId).toBeNull();
|
||||
expect(pending.pendingChangeCount).toBe(1);
|
||||
expect(pending.nextEvalAt).toBeInstanceOf(Date);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(Math.max(0, pending.nextEvalAt!.getTime() - Date.now()));
|
||||
await settled();
|
||||
const generated = (await cards.getById(created.id))!;
|
||||
expect(generated.generatingIssueId).toBeTruthy();
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
expect(await db.select().from(agentWakeupRequests)).toHaveLength(1);
|
||||
await writeback(created.id, generated.generatingIssueId!);
|
||||
expect((await db.select().from(statusCardUpdates).where(eq(statusCardUpdates.generationIssueId, generated.generatingIssueId!))))
|
||||
.toMatchObject([{ status: "ok", finishedAt: expect.any(Date) }]);
|
||||
}
|
||||
await quiet();
|
||||
expect(wakeup).toHaveBeenCalledTimes(relevant ? 1 : 0);
|
||||
if (!relevant) expect(searchedCompanies).toEqual([companyId, companyId]);
|
||||
expect(new Set(searchedCompanies)).toEqual(new Set([companyId]));
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rolls back the summary and its post-commit recheck obligation together", async () => {
|
||||
await start(); const created = await card(); await quiet();
|
||||
const cards = statusCardService(db);
|
||||
const generation = await cards.requestRefresh(created.id, { trigger: "manual", actor });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ checkoutRunId: run!.id }).where(eq(issues.id, generation.generatingIssue!.id));
|
||||
await settled();
|
||||
const before = (await cards.getById(created.id))!;
|
||||
const revisions = await cards.listSummaryRevisions(created.id);
|
||||
await expect(db.transaction(async tx => {
|
||||
await statusCardService(tx as unknown as typeof db).writeSummary(created.id, { markdown: "Rolled back summary", changeSummary: "Rollback", generationIssueId: generation.generatingIssue!.id }, { agentId, runId: run!.id });
|
||||
throw new Error("summary rollback");
|
||||
})).rejects.toThrow("summary rollback");
|
||||
await quiet();
|
||||
expect((await cards.getById(created.id))!).toMatchObject({ generatingIssueId: before.generatingIssueId, nextEvalAt: before.nextEvalAt, nextEvalReason: before.nextEvalReason });
|
||||
expect(await cards.listSummaryRevisions(created.id)).toEqual(revisions);
|
||||
expect(wakeup).not.toHaveBeenCalled(); expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["manual", "interval"] as const)("preserves %s writeback cadence without a reactive recheck", async mode => {
|
||||
const created = await card(mode), cards = statusCardService(db);
|
||||
const generation = await cards.requestRefresh(created.id, { trigger: "manual", actor });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ checkoutRunId: run!.id }).where(eq(issues.id, generation.generatingIssue!.id));
|
||||
const result = await cards.writeSummary(created.id, { markdown: "Current blockers", changeSummary: "Complete", generationIssueId: generation.generatingIssue!.id }, { agentId, runId: run!.id });
|
||||
expect(result.card.generatingIssueId).toBeNull();
|
||||
expect(result.card.nextEvalAt?.getTime() ?? null).toBe(mode === "interval" ? result.card.lastGeneratedAt!.getTime() + 15 * 60_000 : null);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps card deletion scoped to its company and a missing deletion quiet", async () => {
|
||||
const removed = await card();
|
||||
await card();
|
||||
const ownCompany = companyId, ownAgent = agentId;
|
||||
companyId = randomUUID(); agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Other", issuePrefix: `O${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Other summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
|
||||
const other = await card();
|
||||
companyId = ownCompany; agentId = ownAgent;
|
||||
const factory = companySearch.companySearchService;
|
||||
const searchedCompanies: string[] = [];
|
||||
vi.spyOn(companySearch, "companySearchService").mockImplementation(owner => {
|
||||
const search = factory(owner), actual = search.search;
|
||||
search.search = async (...args) => { searchedCompanies.push(args[0]); return actual(...args); };
|
||||
return search;
|
||||
});
|
||||
await start(); await quiet(); searchedCompanies.length = 0;
|
||||
const cards = statusCardService(db);
|
||||
expect(await cards.remove(removed.id)).toMatchObject({ id: removed.id, companyId: ownCompany });
|
||||
await settled();
|
||||
// Deleting a card only reconstructs deadlines; it is not an issue change
|
||||
// that warrants re-running any unchanged reactive card's saved search.
|
||||
expect(searchedCompanies).toEqual([]);
|
||||
expect(await cards.getById(other.id)).toMatchObject({ nextEvalAt: null, generatingIssueId: null, pendingChangeCount: 0 });
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
searchedCompanies.length = 0;
|
||||
expect(await cards.remove(removed.id)).toBeNull();
|
||||
await quiet();
|
||||
expect(searchedCompanies).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("coalesces one hundred committed source writes without delaying another company's saved deadline", async () => {
|
||||
const reactive = await card();
|
||||
const originalCompany = companyId, originalAgent = agentId;
|
||||
companyId = randomUUID(); agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Other", issuePrefix: `O${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Other summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
|
||||
const other = await card("interval");
|
||||
const otherIssue = await issueService(db).create(companyId, { title: "Other interval blocker", status: "blocked", priority: "medium" });
|
||||
const otherReactive = await card();
|
||||
companyId = originalCompany; agentId = originalAgent;
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
const originalRun = service.run;
|
||||
let running: Promise<unknown> | undefined;
|
||||
const run = vi.spyOn(service, "run").mockImplementation(signal => {
|
||||
const result = originalRun(signal); running = result; return result;
|
||||
});
|
||||
await start(service); await settled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
// Give each committed producer and any actual domain work time to settle.
|
||||
// This prevents incidental overlap from hiding per-write reconstruction.
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await running; await immediate();
|
||||
}
|
||||
};
|
||||
await flush();
|
||||
const select = vi.spyOn(db, "select");
|
||||
const factory = companySearch.companySearchService;
|
||||
const searchedCompanies: string[] = [];
|
||||
vi.spyOn(companySearch, "companySearchService").mockImplementation(owner => {
|
||||
const search = factory(owner);
|
||||
const actual = search.search;
|
||||
search.search = async (...args) => { searchedCompanies.push(args[0]); return actual(...args); };
|
||||
return search;
|
||||
});
|
||||
run.mockClear();
|
||||
const burstStarted = Date.now();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const issue = await issueService(db).create(companyId, { title: `Burst blocker ${i}`, status: "blocked", priority: "medium" });
|
||||
await flush();
|
||||
await issueService(db).addComment(issue.id, `Human update ${i}`, { userId: "board-user" });
|
||||
await flush();
|
||||
}
|
||||
await flush();
|
||||
// One eager reconciliation after idle and one bounded batched continuation;
|
||||
// the producers themselves are not counted as background SQL.
|
||||
const dueReads = () => select.mock.calls.filter(([fields]) => fields && "card" in fields && fields.card === statusCards).length;
|
||||
const nextReads = () => select.mock.calls.filter(([fields]) => fields && "at" in fields && fields.at === statusCards.nextEvalAt).length;
|
||||
const elapsedMs = Date.now() - burstStarted;
|
||||
const maximum = Math.ceil(elapsedMs / 1000) + 2;
|
||||
console.info("status-card burst", { elapsedMs, maximum, runs: run.mock.calls.length, due: dueReads(), next: nextReads(), searches: searchedCompanies.length });
|
||||
expect(run.mock.calls.length).toBeLessThanOrEqual(maximum);
|
||||
expect(dueReads()).toBeLessThanOrEqual(maximum); expect(nextReads()).toBeLessThanOrEqual(maximum);
|
||||
expect(searchedCompanies.length).toBeLessThanOrEqual(maximum);
|
||||
expect(searchedCompanies.length).toBeGreaterThan(0);
|
||||
expect(new Set(searchedCompanies)).toEqual(new Set([originalCompany]));
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
// Re-register with an existing durable deadline inside the coalescing
|
||||
// window. A company hint must not make that real deadline wait one second.
|
||||
await coordinator!.stop(); coordinator = undefined;
|
||||
const dueAt = Date.now() + 500;
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(dueAt), nextEvalReason: "refresh" }).where(eq(statusCards.id, other.id));
|
||||
await start(service); await flush();
|
||||
await issueService(db).addComment(otherIssue.id, "Another company's coalesced human update", { userId: "board-user" });
|
||||
await issueService(db).addComment((await db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, originalCompany)))[0]!.id,
|
||||
"More changes while another company is due", { userId: "board-user" });
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(500); await flush();
|
||||
await vi.waitFor(() => expect(wakeup).toHaveBeenCalledOnce());
|
||||
expect(Date.now()).toBeLessThan(dueAt + 500);
|
||||
expect(wakeup.mock.calls[0]![0]).not.toBe(originalAgent);
|
||||
const generated = (await statusCardService(db).getById(other.id))!;
|
||||
expect(generated.generatingIssueId).toBeTruthy();
|
||||
expect((await statusCardService(db).getById(reactive.id))!.pendingChangeCount).toBeGreaterThan(0);
|
||||
expect((await statusCardService(db).getById(otherReactive.id))!.pendingChangeCount).toBeGreaterThan(0);
|
||||
expect(searchedCompanies).toContain(other.companyId);
|
||||
// Complete all ordinary results, then prove there is no recurring batch
|
||||
// timer once the requested work has settled.
|
||||
companyId = other.companyId; agentId = other.agentId!;
|
||||
await writeback(other.id, generated.generatingIssueId!);
|
||||
await statusCardService(db).update((await statusCardService(db).getById(other.id))!, { archived: true }, actor);
|
||||
companyId = originalCompany; agentId = originalAgent;
|
||||
await vi.advanceTimersByTimeAsync(120_000); await settled(); await flush();
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(reactive.id))!.generatingIssueId).toBeTruthy());
|
||||
const pending = (await statusCardService(db).getById(reactive.id))!;
|
||||
expect(pending.generatingIssueId).toBeTruthy();
|
||||
await writeback(reactive.id, pending.generatingIssueId!);
|
||||
const otherPending = (await statusCardService(db).getById(otherReactive.id))!;
|
||||
expect(otherPending.generatingIssueId).toBeTruthy();
|
||||
companyId = other.companyId; agentId = other.agentId!;
|
||||
await writeback(otherReactive.id, otherPending.generatingIssueId!);
|
||||
companyId = originalCompany; agentId = originalAgent;
|
||||
select.mockRestore();
|
||||
await quiet(); expect(errors).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it("bounds active-hours conversion work across twenty cards and repeated ordinary company writes", async () => {
|
||||
vi.setSystemTime(new Date("2026-11-02T18:00:00Z"));
|
||||
const hoursPolicy = { ...policy(), activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } };
|
||||
const cards = [];
|
||||
for (let i = 0; i < 20; i++) cards.push(await card("reactive", hoursPolicy));
|
||||
await start(); await quiet();
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
const actualParts = RealFormatter.prototype.formatToParts;
|
||||
let constructions = 0, conversions = 0;
|
||||
const constructor = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
if (args[0] === "en-GB" && args[1]?.hourCycle === "h23") constructions++;
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
const parts = vi.spyOn(RealFormatter.prototype, "formatToParts").mockImplementation(function (this: Intl.DateTimeFormat, date) {
|
||||
if (this.resolvedOptions().locale === "en-GB" && this.resolvedOptions().hourCycle === "h23") conversions++;
|
||||
return actualParts.call(this, date);
|
||||
});
|
||||
const target = await issueService(db).create(companyId, { title: "Closed-hours blocker", status: "blocked", priority: "medium" });
|
||||
await settled();
|
||||
expect(constructions).toBeLessThanOrEqual(1);
|
||||
expect(conversions).toBeLessThanOrEqual(1000);
|
||||
const cold = { constructions, conversions };
|
||||
constructions = 0; conversions = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await issueService(db).addComment(target.id, `Closed-hours human update ${i}`, { userId: "board-user" });
|
||||
await settled();
|
||||
}
|
||||
expect(constructions).toBe(0);
|
||||
expect(conversions).toBeGreaterThanOrEqual(200);
|
||||
expect(conversions).toBeLessThanOrEqual(800);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
const opening = new Date("2026-11-03T09:00:00Z");
|
||||
for (const created of cards) expect((await statusCardService(db).getById(created.id)))
|
||||
.toMatchObject({ state: "paused_hours", pendingChangeCount: 1, nextEvalAt: opening });
|
||||
process.stdout.write(JSON.stringify({ measurement: "status-card company active-hours workload", cards: 20,
|
||||
sourceWrites: 11, cold, warm: { constructions, conversions } }) + "\n");
|
||||
constructor.mockRestore(); parts.mockRestore();
|
||||
await vi.advanceTimersByTimeAsync(opening.getTime() - Date.now()); await settled();
|
||||
expect(wakeup).toHaveBeenCalledTimes(20);
|
||||
for (const created of cards) {
|
||||
const generated = (await statusCardService(db).getById(created.id))!;
|
||||
expect(generated.generatingIssueId).toBeTruthy();
|
||||
await writeback(created.id, generated.generatingIssueId!);
|
||||
}
|
||||
await quiet(); expect(errors).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("cancels archived or disabled future work and replaces an earlier interval deadline", async () => {
|
||||
await start(); let created = await card("interval"); await settled();
|
||||
const old = coordinator!.nextWakeAt();
|
||||
created = await statusCardService(db).update(created, { refreshPolicy: { ...policy("interval"), intervalMinutes: 1 } }, actor);
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await Promise.allSettled([...evaluations]);
|
||||
expect(coordinator!.nextWakeAt()).toBeLessThan(old!);
|
||||
});
|
||||
await statusCardService(db).update(created, { archived: true }, actor); await quiet();
|
||||
expect(wakeup).not.toHaveBeenCalled(); expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("restores overdue debt after the feature is disabled across its saved deadline", async () => {
|
||||
await start(); const created = await card(); await quiet();
|
||||
await issueService(db).create(companyId, { title: "Disabled blocker", status: "blocked", priority: "medium" });
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
await instanceSettingsService(db).updateExperimental({ enableStatusCards: false }); await quiet();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toEqual(pending.nextEvalAt);
|
||||
await instanceSettingsService(db).updateExperimental({ enableStatusCards: true });
|
||||
await vi.waitFor(() => expect(wakeup).toHaveBeenCalledOnce());
|
||||
await writeback(created.id, (await statusCardService(db).getById(created.id))!.generatingIssueId!); await quiet();
|
||||
});
|
||||
|
||||
it("does not react to rolled-back or nested uncommitted card configuration", async () => {
|
||||
await start(); const created = await card("manual"); await quiet();
|
||||
await expect(db.transaction(async tx => {
|
||||
await statusCardService(tx as unknown as typeof db).update(created, { refreshPolicy: policy("interval") }, actor);
|
||||
await vi.advanceTimersByTimeAsync(30_000); expect(wakeup).not.toHaveBeenCalled();
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
await quiet(); expect((await statusCardService(db).getById(created.id))?.nextEvalAt).toBeNull();
|
||||
await db.transaction(async tx => {
|
||||
await tx.transaction(async nested => {
|
||||
await statusCardService(nested as unknown as typeof db).update(created, { refreshPolicy: policy("interval") }, actor);
|
||||
});
|
||||
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
||||
expect((await statusCardService(db).getById(created.id))?.nextEvalAt).toBeNull();
|
||||
});
|
||||
await settled(); expect(coordinator!.nextWakeAt()).not.toBeNull(); expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes competing connections when one source event preempts a future evaluation", async () => {
|
||||
const created = await card();
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() + 3_600_000) }).where(eq(statusCards.id, created.id));
|
||||
await issueService(db).create(companyId, { title: "Concurrent blocker", status: "blocked", priority: "medium", createdByUserId: "board-user" });
|
||||
const first = statusCardEvaluationService(db, { wakeup });
|
||||
const second = statusCardEvaluationService(db, { wakeup });
|
||||
first.changed(new Set([`changes:${companyId}`])); second.changed(new Set([`changes:${companyId}`]));
|
||||
await Promise.all([first.run(new AbortController().signal), second.run(new AbortController().signal)]);
|
||||
const pending = await statusCardService(db).getById(created.id);
|
||||
expect(pending?.pendingChangeCount).toBeGreaterThan(0);
|
||||
expect(pending?.nextEvalAt?.getTime()).toBeLessThan(Date.now() + 3_600_000);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await Promise.all([first.run(new AbortController().signal), second.run(new AbortController().signal)]);
|
||||
const updated = await statusCardService(db).getById(created.id);
|
||||
expect(updated?.generatingIssueId).toBeTruthy(); expect(wakeup).toHaveBeenCalledOnce();
|
||||
expect(await db.select().from(statusCardUpdates).then(rows => rows.filter(row => row.generationIssueId === updated?.generatingIssueId)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("finishes generation and writeback with a one-connection pool and releases dedicated locks", async () => {
|
||||
vi.useRealTimers();
|
||||
const previousDb = db;
|
||||
db = createDb(external ?? temporary!.connectionString, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
||||
try {
|
||||
const created = await card("reactive", { ...policy(), debounceSeconds: 1 });
|
||||
const countLocks = async () => Number((await db.execute<{ count: string }>(sql`
|
||||
select count(*) from pg_stat_activity where datname = current_database()
|
||||
and application_name = 'paperclip-workspace-finalization-lock'`))[0]!.count);
|
||||
const baseline = await countLocks();
|
||||
await issueService(db).create(companyId, { title: "Pool-one blocker", status: "blocked", priority: "medium" });
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
const acquired = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const holder = withDedicatedDbConnection(db, connection => connection.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${created.id}, 0))`);
|
||||
acquired.resolve(); await release.promise;
|
||||
}));
|
||||
await acquired.promise;
|
||||
try {
|
||||
await service.run(new AbortController().signal);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect(await service.next()).toBeGreaterThan(Date.now());
|
||||
} finally { release.resolve(); await holder; }
|
||||
await service.run(new AbortController().signal);
|
||||
expect((await statusCardService(db).getById(created.id))!.pendingChangeCount).toBeGreaterThan(0);
|
||||
await vi.waitFor(async () => {
|
||||
await service.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
}, { timeout: 5000, interval: 100 });
|
||||
await writeback(created.id, (await statusCardService(db).getById(created.id))!.generatingIssueId!);
|
||||
await service.run(new AbortController().signal);
|
||||
expect(await service.next()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
await vi.waitFor(async () => expect(await countLocks()).toBe(baseline));
|
||||
// Failure exits also release the physical connection and advisory lock.
|
||||
await db.update(statusCards).set({ queries: [{ scope: "invalid" } as never], nextEvalAt: new Date() }).where(eq(statusCards.id, created.id));
|
||||
await service.run(new AbortController().signal);
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalReason).toBe("retry");
|
||||
await vi.waitFor(async () => expect(await countLocks()).toBe(baseline));
|
||||
await withDedicatedDbConnection(db, connection => connection.transaction(async tx => {
|
||||
expect((await tx.execute<{ acquired: boolean }>(sql`select pg_try_advisory_xact_lock(hashtextextended(${created.id}, 0)) as acquired`))[0]!.acquired).toBe(true);
|
||||
}));
|
||||
} finally { await db.$client.end({ timeout: 1 }); db = previousDb; }
|
||||
}, 15_000);
|
||||
|
||||
it.each([false, true])("recovers a lost committed hint before a future membership deadline (legacy=%s)", async legacy => {
|
||||
await issueService(db).create(companyId, { title: "Original blocker", status: "blocked", priority: "medium" });
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1d" });
|
||||
const created = await card();
|
||||
expect(created.nextEvalReason).toBe("membership");
|
||||
const oldExpiry = created.nextEvalAt!.getTime();
|
||||
// No coordinator exists to receive this committed source hint. A fresh
|
||||
// service has no in-memory hint and must reconstruct from durable truth.
|
||||
await issueService(db).create(companyId, { title: "Committed before crash", status: "blocked", priority: "medium" });
|
||||
if (legacy) await db.update(statusCards).set({ nextEvalReason: null }).where(eq(statusCards.id, created.id));
|
||||
const restarted = statusCardEvaluationService(db, { wakeup });
|
||||
await restarted.run(new AbortController().signal);
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBe(1);
|
||||
expect(pending.nextEvalReason).toBe("policy");
|
||||
expect(pending.nextEvalAt!.getTime()).toBeLessThan(oldExpiry);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
vi.setSystemTime(pending.nextEvalAt!); await restarted.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
await writeback(created.id, (await statusCardService(db).getById(created.id))!.generatingIssueId!);
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalReason).toBe("membership");
|
||||
});
|
||||
|
||||
it("preserves a legacy ambiguous five-minute claim without extending it on restart", async () => {
|
||||
const created = await card();
|
||||
const retry = new Date(Date.now() + 240_000);
|
||||
await db.update(statusCards).set({ nextEvalAt: retry, nextEvalReason: null }).where(eq(statusCards.id, created.id));
|
||||
await issueService(db).create(companyId, { title: "Unconsumed source", status: "blocked", priority: "medium" });
|
||||
const restarted = statusCardEvaluationService(db, { wakeup });
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(await restarted.next()).toBe(retry.getTime());
|
||||
vi.setSystemTime(retry); await restarted.run(new AbortController().signal);
|
||||
expect((await statusCardService(db).getById(created.id))!.pendingChangeCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(["debounce", "hours", "rate", "budget"] as const)("restores pending %s eligibility after restart without advancing it or acting early", async gate => {
|
||||
vi.setSystemTime(new Date("2026-11-02T18:00:00Z"));
|
||||
const refreshPolicy = { ...policy(), ...(gate === "hours" ? { activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } } : {}),
|
||||
...(gate === "rate" ? { maxUpdatesPerHour: 1 } : {}), ...(gate === "budget" ? { dailyTokenCap: 100 } : {}) };
|
||||
const created = await card("reactive", refreshPolicy);
|
||||
if (gate === "rate" || gate === "budget") await db.insert(statusCardUpdates).values({ cardId: created.id, kind: "full", trigger: "reactive",
|
||||
status: "ok", startedAt: new Date(Date.now() - (gate === "rate" ? 30 * 60_000 : 60_000)), finishedAt: new Date(), inputTokens: gate === "budget" ? 100 : 0 });
|
||||
await issueService(db).create(companyId, { title: "Saved blocker", status: "blocked", priority: "medium", createdByUserId: "board-user" });
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
await service.run(new AbortController().signal);
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBeGreaterThan(0); expect(pending.nextEvalAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
const restarted = statusCardEvaluationService(db, { wakeup });
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(await restarted.next()).toBe(pending.nextEvalAt!.getTime());
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toEqual(pending.nextEvalAt);
|
||||
vi.setSystemTime(pending.nextEvalAt!.getTime() - 1); await restarted.run(new AbortController().signal);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
vi.setSystemTime(pending.nextEvalAt!); await restarted.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("unions startup reconstruction with a scoped source change in another company", async () => {
|
||||
const firstCompany = companyId;
|
||||
const first = await card();
|
||||
await issueService(db).create(companyId, { title: "First blocker", status: "blocked", priority: "medium" });
|
||||
companyId = randomUUID(); agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Other", issuePrefix: `X${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Other summarizer", role: "general", status: "idle", adapterType: "process", adapterConfig: {} });
|
||||
const second = await card();
|
||||
await issueService(db).create(companyId, { title: "Second blocker", status: "blocked", priority: "medium" });
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
service.changed(new Set([`changes:${companyId}`]));
|
||||
await service.run(new AbortController().signal);
|
||||
const cards = statusCardService(db);
|
||||
expect((await cards.getById(first.id))?.companyId).toBe(firstCompany);
|
||||
expect((await cards.getById(first.id))?.pendingChangeCount).toBeGreaterThan(0);
|
||||
expect((await cards.getById(second.id))?.pendingChangeCount).toBeGreaterThan(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000); await service.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledTimes(2);
|
||||
expect(new Set(await db.select().from(agentWakeupRequests).then(rows => rows.map(row => row.companyId))))
|
||||
.toEqual(new Set([firstCompany, companyId]));
|
||||
});
|
||||
|
||||
it("keeps ignored issue activity quiet and reacts to human comments and document search membership", async () => {
|
||||
await start(); await card(); await quiet();
|
||||
await issueService(db).create(companyId, { title: "Ignored todo", status: "todo", priority: "medium" });
|
||||
await quiet(); expect(wakeup).not.toHaveBeenCalled();
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "all", q: "launch" });
|
||||
const searchCard = await card(); await quiet();
|
||||
const target = await issueService(db).create(companyId, { title: "Ordinary task", status: "todo", priority: "medium" });
|
||||
await quiet();
|
||||
await documentService(db).upsertIssueDocument({ issueId: target.id, key: "plan", format: "markdown", body: "launch evidence", createdByUserId: "board-user" });
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(searchCard.id))?.pendingChangeCount).toBeGreaterThan(0));
|
||||
await vi.advanceTimersByTimeAsync(60_000); await settled(); expect(wakeup).toHaveBeenCalledOnce();
|
||||
await writeback(searchCard.id, (await statusCardService(db).getById(searchCard.id))!.generatingIssueId!); await quiet();
|
||||
await issueService(db).addComment(target.id, "A human launch update", { userId: "board-user" });
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(searchCard.id))?.pendingChangeCount).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it("retains relative query membership expiry across restart and emits the eventual removal", async () => {
|
||||
const now = new Date();
|
||||
const target = await issueService(db).create(companyId, { title: "Expiring blocker", status: "blocked", priority: "medium" });
|
||||
await db.update(issues).set({ updatedAt: now }).where(eq(issues.id, target.id));
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1h" });
|
||||
const created = await card();
|
||||
const expires = now.getTime() + 3_600_001;
|
||||
expect(created.nextEvalAt?.getTime()).toBe(expires);
|
||||
const restarted = statusCardEvaluationService(db, { wakeup });
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(await restarted.next()).toBe(expires);
|
||||
vi.setSystemTime(expires - 1); await restarted.run(new AbortController().signal); expect(wakeup).not.toHaveBeenCalled();
|
||||
vi.setSystemTime(expires); await restarted.run(new AbortController().signal);
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBe(1); expect(pending.nextEvalAt!.getTime()).toBe(expires + 60_000);
|
||||
vi.setSystemTime(pending.nextEvalAt!); await restarted.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("labels an interval writeback by its refresh deadline despite earlier membership expiry", async () => {
|
||||
const target = await issueService(db).create(companyId, { title: "Interval blocker", status: "blocked", priority: "medium" });
|
||||
const updatedAt = new Date(Date.now() - 59 * 60_000);
|
||||
await db.update(issues).set({ updatedAt }).where(eq(issues.id, target.id));
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1h" });
|
||||
let membership: Date | null = null;
|
||||
await companySearch.companySearchService(db).search(companyId, compiledQuery, {
|
||||
issueResultsOnly: true, onNextMembershipChange: at => { membership = at; },
|
||||
});
|
||||
expect(membership).toEqual(new Date(updatedAt.getTime() + 3_600_001));
|
||||
const created = await card("interval");
|
||||
expect(created.nextEvalAt).toEqual(new Date(created.lastGeneratedAt!.getTime() + 15 * 60_000));
|
||||
expect(created.nextEvalAt!.getTime()).toBeGreaterThan(updatedAt.getTime() + 3_600_001);
|
||||
expect(created.nextEvalReason).toBe("refresh");
|
||||
});
|
||||
|
||||
it("uses expiry of an off-page candidate to refresh offset-ranked query membership", async () => {
|
||||
const now = new Date();
|
||||
const tasks = await Promise.all((["critical", "high", "medium"] as const).map(priority => issueService(db).create(companyId, {
|
||||
title: `${priority} blocker`, priority, status: "blocked",
|
||||
})));
|
||||
await db.update(issues).set({ updatedAt: new Date(now.getTime() - 59 * 60_000) }).where(eq(issues.id, tasks[0]!.id));
|
||||
for (const task of tasks.slice(1)) await db.update(issues).set({ updatedAt: now }).where(eq(issues.id, task.id));
|
||||
compiledQuery = companySearchQuerySchema.parse({ scope: "issues", status: ["blocked"], updatedWithin: "1h", sort: "priority", limit: 1, offset: 1 });
|
||||
const created = await card();
|
||||
expect(Object.keys(created.fingerprint!)).toEqual([tasks[1]!.id]);
|
||||
const expires = now.getTime() + 60_001;
|
||||
expect(created.nextEvalAt?.getTime()).toBe(expires);
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
vi.setSystemTime(expires); await service.run(new AbortController().signal);
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBe(2); expect(wakeup).not.toHaveBeenCalled();
|
||||
vi.setSystemTime(pending.nextEvalAt!); await service.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
const [update] = await db.select().from(statusCardUpdates).where(eq(statusCardUpdates.generationIssueId, (await statusCardService(db).getById(created.id))!.generatingIssueId!));
|
||||
expect(new Set(update!.changes.map(change => change.issueId))).toEqual(new Set([tasks[1]!.id, tasks[2]!.id]));
|
||||
});
|
||||
|
||||
it("retains a scoped retry while another evaluator owns the card lock", async () => {
|
||||
const created = await card();
|
||||
await issueService(db).create(companyId, { title: "Locked blocker", status: "blocked", priority: "medium" });
|
||||
const acquired = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const holder = db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${created.id}, 0))`);
|
||||
acquired.resolve(); await release.promise;
|
||||
});
|
||||
await acquired.promise;
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
service.changed(new Set([`changes:${companyId}`]));
|
||||
try {
|
||||
await service.run(new AbortController().signal);
|
||||
expect(await service.next()).toBeGreaterThan(Date.now());
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toBeNull();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
} finally { release.resolve(); await holder; }
|
||||
await vi.advanceTimersByTimeAsync(30_000); await service.run(new AbortController().signal);
|
||||
const pending = (await statusCardService(db).getById(created.id))!;
|
||||
expect(pending.pendingChangeCount).toBeGreaterThan(0);
|
||||
vi.setSystemTime(pending.nextEvalAt!); await service.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([false, true])("rechecks current policy after a committed edit during held search (changes=%s)", async changed => {
|
||||
const created = await card();
|
||||
if (changed) await issueService(db).create(companyId, { title: "Policy race blocker", status: "blocked", priority: "medium" });
|
||||
const entered = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const factory = companySearch.companySearchService;
|
||||
let hold = true;
|
||||
const searches = vi.fn();
|
||||
vi.spyOn(companySearch, "companySearchService").mockImplementation(owner => {
|
||||
const service = factory(owner), actual = service.search;
|
||||
service.search = async (...args) => {
|
||||
searches();
|
||||
const result = await actual(...args);
|
||||
if (hold) { hold = false; entered.resolve(); await release.promise; }
|
||||
return result;
|
||||
};
|
||||
return service;
|
||||
});
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
const pass = service.run(new AbortController().signal);
|
||||
await entered.promise;
|
||||
const cards = statusCardService(db);
|
||||
let scheduled!: Date;
|
||||
try {
|
||||
scheduled = (await cards.update((await cards.getById(created.id))!, { refreshPolicy: policy("interval") }, actor)).nextEvalAt!;
|
||||
} finally { release.resolve(); }
|
||||
await pass;
|
||||
const current = (await cards.getById(created.id))!;
|
||||
expect(current.refreshPolicy.mode).toBe("interval");
|
||||
expect(current.nextEvalAt!.getTime()).toBe(scheduled.getTime());
|
||||
expect(current.nextEvalReason).toBe("refresh");
|
||||
expect(searches).toHaveBeenCalledOnce();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
const intervalCard = (await cards.getById(created.id))!;
|
||||
await issueService(db).create(companyId, { title: "Next interval blocker", status: "blocked", priority: "medium" });
|
||||
vi.setSystemTime(intervalCard.nextEvalAt!);
|
||||
await service.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps future failure retries fenced from ordinary company hints and permits explicit policy recovery", async () => {
|
||||
const created = await card();
|
||||
const factory = companySearch.companySearchService;
|
||||
let failing = true;
|
||||
const searches = vi.fn();
|
||||
vi.spyOn(companySearch, "companySearchService").mockImplementation(owner => {
|
||||
const service = factory(owner), actual = service.search;
|
||||
service.search = async (...args) => { searches(); if (failing) throw new Error("Controlled search unavailable"); return actual(...args); };
|
||||
return service;
|
||||
});
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
await service.run(new AbortController().signal);
|
||||
const failed = (await statusCardService(db).getById(created.id))!;
|
||||
expect(failed.nextEvalReason).toBe("retry");
|
||||
const retryAt = failed.nextEvalAt!.getTime();
|
||||
expect(searches).toHaveBeenCalledOnce();
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await issueService(db).create(companyId, { title: `Retry blocker ${index}`, status: "blocked", priority: "medium" });
|
||||
service.changed(new Set([`changes:${companyId}`]));
|
||||
await service.run(new AbortController().signal);
|
||||
}
|
||||
expect(searches).toHaveBeenCalledOnce();
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalAt!.getTime()).toBe(retryAt);
|
||||
const restarted = statusCardEvaluationService(db, { wakeup });
|
||||
restarted.changed(new Set([`changes:${companyId}`]));
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(searches).toHaveBeenCalledOnce();
|
||||
expect(await restarted.next()).toBe(retryAt);
|
||||
vi.setSystemTime(retryAt);
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(searches).toHaveBeenCalledTimes(2);
|
||||
const cards = statusCardService(db);
|
||||
failing = false;
|
||||
await cards.update((await cards.getById(created.id))!, { refreshPolicy: { ...policy(), debounceSeconds: 1 } }, actor);
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(searches).toHaveBeenCalledTimes(3);
|
||||
const pending = (await cards.getById(created.id))!;
|
||||
expect(pending.nextEvalReason).toBe("policy");
|
||||
vi.setSystemTime(pending.nextEvalAt!);
|
||||
await restarted.run(new AbortController().signal);
|
||||
expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains the durable five-minute retry after evaluation failure across restart and drain", async () => {
|
||||
const created = await card();
|
||||
await db.update(statusCards).set({ queries: [{ scope: "invalid" } as never], nextEvalAt: new Date() }).where(eq(statusCards.id, created.id));
|
||||
const service = statusCardEvaluationService(db, { wakeup });
|
||||
await service.run(new AbortController().signal);
|
||||
const failed = (await statusCardService(db).getById(created.id))!;
|
||||
const retryAt = failed.nextEvalAt!.getTime();
|
||||
expect(retryAt).toBeGreaterThan(Date.now() + 299_000); expect(wakeup).not.toHaveBeenCalled();
|
||||
await start(); expect(coordinator!.nextWakeAt()).toBe(retryAt);
|
||||
draining = true; await vi.advanceTimersByTimeAsync(5 * 60_000); await settled(); expect(wakeup).not.toHaveBeenCalled();
|
||||
// Repair the controlled malformed stored-query fault; no tested result is precreated.
|
||||
await db.update(statusCards).set({ queries: [compiledQuery] }).where(eq(statusCards.id, created.id));
|
||||
draining = false; await vi.advanceTimersByTimeAsync(30_000); await quiet();
|
||||
expect((await statusCardService(db).getById(created.id))!.nextEvalAt).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels failed assignments without rerunning a completed generation and settles after retry", async () => {
|
||||
await start(); const created = await card(); await quiet();
|
||||
wakeup.mockRejectedValueOnce(new Error("assignment unavailable"));
|
||||
await issueService(db).create(companyId, { title: "Assignment blocker", status: "blocked", priority: "medium" });
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
|
||||
await vi.advanceTimersByTimeAsync(60_000); await settled();
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((await statusCardService(db).getById(created.id))!.state).toBe("error");
|
||||
});
|
||||
const failed = (await statusCardService(db).getById(created.id))!;
|
||||
expect(failed).toMatchObject({ state: "error", generatingIssueId: null, nextEvalAt: null, nextEvalReason: null });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const history = await db.select().from(statusCardUpdates).where(eq(statusCardUpdates.cardId, created.id));
|
||||
const ledger = history.find(row => row.status === "failed")!;
|
||||
expect(ledger.finishedAt).toBeInstanceOf(Date);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, ledger.generationIssueId!)))[0]?.status).toBe("cancelled");
|
||||
const count = wakeup.mock.calls.length; await quiet(); expect(wakeup).toHaveBeenCalledTimes(count);
|
||||
// An explicit ordinary refresh is still the user's supported retry action.
|
||||
const retry = await statusCardService(db).requestRefresh(created.id, { trigger: "manual", actor });
|
||||
expect(retry.generatingIssue).toBeTruthy(); await writeback(created.id, retry.generatingIssue!.id); await quiet();
|
||||
expect((await statusCardService(db).getById(created.id))!.state).toBe("active");
|
||||
});
|
||||
|
||||
it("budget-owned company resume reactivates reactive work committed while paused", async () => {
|
||||
await start(); const created = await card(); await quiet();
|
||||
const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(companyId, { scopeType: "company", scopeId: companyId, amount: 100, hardStopEnabled: true, notifyEnabled: false }, "board-user");
|
||||
const [cost] = await db.insert(costEvents).values({ companyId, agentId, provider: "openai", biller: "openai", billingType: "metered_api", model: "test",
|
||||
costCents: 200, occurredAt: new Date() }).returning();
|
||||
await budgets.evaluateCostEvent(cost!);
|
||||
expect((await db.select().from(companies).where(eq(companies.id, companyId)))[0]?.status).toBe("paused");
|
||||
await quiet();
|
||||
await issueService(db).create(companyId, { title: "Paused blocker", status: "blocked", priority: "medium" }); await quiet();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
await budgets.upsertPolicy(companyId, { scopeType: "company", scopeId: companyId, amount: 1000 }, "board-user");
|
||||
expect((await db.select().from(companies).where(eq(companies.id, companyId)))[0]?.status).toBe("active");
|
||||
await vi.waitFor(async () => expect((await statusCardService(db).getById(created.id))?.pendingChangeCount).toBeGreaterThan(0));
|
||||
await vi.advanceTimersByTimeAsync(60_000); await settled(); expect(wakeup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the first write during drain until resume", async () => {
|
||||
await start(); const created = await card("manual"); await quiet();
|
||||
draining = true;
|
||||
await statusCardService(db).update(created, { refreshPolicy: policy("interval") }, actor);
|
||||
await settled(); expect(wakeup).not.toHaveBeenCalled();
|
||||
draining = false; await vi.advanceTimersByTimeAsync(30_000); await settled();
|
||||
expect(coordinator!.nextWakeAt()).not.toBeNull(); expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { installDatabaseWorkSignals, subscribeDatabaseWork } from "../../../packages/db/src/work-signals.js";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import { notifyExecutionRunWork, writeExecutionControl } from "../services/execution-control-notifications.js";
|
||||
|
||||
it.each([false, true])("keeps execution queue consumers intact and emits only a known status-card company scope (%s)", async scoped => {
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: async (fn: (tx: object) => Promise<unknown>) =>
|
||||
fn({ execute: async () => [{ xid: "1" }] }) });
|
||||
const queues = [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.executionReplacement,
|
||||
DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors];
|
||||
const settled: Array<{ queue: string; hints?: string[] }> = [];
|
||||
const unsubscribe = [...queues, DELIVERY_QUEUES.statusCardEvaluation, DELIVERY_QUEUES.workspaceRecovery].map(queue => subscribeDatabaseWork(owner, queue, (event, hints) => {
|
||||
if (event === "settled") settled.push({ queue, hints: hints ? [...hints] : undefined });
|
||||
}));
|
||||
try {
|
||||
const scope = scoped ? { companyId: "changed-company", runId: "changed-run" } : undefined;
|
||||
await owner.transaction(async tx => {
|
||||
await notifyExecutionRunWork(tx, scope);
|
||||
expect(settled).toEqual([]);
|
||||
});
|
||||
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([...queues, ...(scoped ? [DELIVERY_QUEUES.statusCardEvaluation] : [])]));
|
||||
expect(settled.find(row => row.queue === DELIVERY_QUEUES.statusCardEvaluation)?.hints)
|
||||
.toEqual(scoped ? ["changes:changed-company"] : undefined);
|
||||
settled.length = 0;
|
||||
const write = vi.fn(async () => "written");
|
||||
expect(await writeExecutionControl(owner as never, DELIVERY_QUEUES.workspaceRecovery, write, scope)).toBe("written");
|
||||
expect(write).toHaveBeenCalledOnce();
|
||||
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([DELIVERY_QUEUES.issueMonitors, DELIVERY_QUEUES.workspaceRecovery,
|
||||
...(scoped ? [DELIVERY_QUEUES.statusCardEvaluation] : [])]));
|
||||
} finally { for (const stop of unsubscribe) stop(); }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { statusCardRefreshPolicySchema } from "@paperclipai/shared";
|
||||
import {
|
||||
chooseStatusCardUpdateKind,
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
filterStatusCardChanges,
|
||||
isWithinStatusCardActiveHours,
|
||||
nextStatusCardEvaluationAt,
|
||||
nextStatusCardPolicyEligibility,
|
||||
statusCardChangesHash,
|
||||
} from "../services/status-card-update-engine.js";
|
||||
|
||||
describe("status card update engine", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const defaultPolicy = statusCardRefreshPolicySchema.parse({ mode: "interval", intervalMinutes: 15 });
|
||||
|
||||
it("retains non-terminal and terminal status transitions plus membership changes", () => {
|
||||
@@ -78,6 +80,149 @@ describe("status card update engine", () => {
|
||||
expect(evaluateStatusCardPolicy({ policy: hours, now: new Date("2026-07-23T18:00:00.000Z"), lastChangeAt: null, updatesLastHour: 0, tokensToday: 0, manual: false }).action).toBe("pause_hours");
|
||||
});
|
||||
|
||||
it("disarms unchanged reactive evaluation while retaining exact pending eligibility", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 90, maxUpdatesPerHour: 2,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } });
|
||||
const now = new Date("2026-07-23T16:59:30Z");
|
||||
expect(nextStatusCardEvaluationAt(policy, now)).toBeNull();
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: now, completedUpdateTimes: [], tokensToday: 0 }))
|
||||
.toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
const open = new Date("2026-07-23T14:00:00Z");
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now: open, lastChangeAt: new Date("2026-07-23T13:00:00Z"),
|
||||
completedUpdateTimes: [new Date("2026-07-23T13:30:00Z"), new Date("2026-07-23T13:45:00Z")], tokensToday: 0 }))
|
||||
.toEqual(new Date("2026-07-23T14:30:00.001Z"));
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now: open, lastChangeAt: open, completedUpdateTimes: [], tokensToday: 100_000 }))
|
||||
.toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
});
|
||||
|
||||
it("retains empty-window pending changes without a recurring eligibility scan", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "09:00", timezone: "UTC" } });
|
||||
const now = new Date("2026-07-23T14:00:00Z");
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: now, completedUpdateTimes: [], tokensToday: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds empty-window formatter work before any timezone conversion", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "09:00", timezone: "Pacific/Chatham" } });
|
||||
const formatter = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function () {
|
||||
throw new Error("Empty active hours must not construct a formatter");
|
||||
});
|
||||
const now = new Date("2026-07-23T14:00:00Z");
|
||||
expect(isWithinStatusCardActiveHours(policy, now)).toBe(false);
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: now, completedUpdateTimes: [], tokensToday: 0 })).toBeNull();
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds formatter construction and reuses the next opening across cards and source writes", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone: "America/St_Johns" } });
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
let constructions = 0;
|
||||
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
if (++constructions > 1) throw new Error("Card evaluation repeatedly constructed timezone formatters");
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
const conversions = vi.spyOn(RealFormatter.prototype, "formatToParts");
|
||||
const now = new Date("2026-07-23T22:00:00Z");
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
const started = performance.now();
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date("2026-07-24T11:30:00Z"));
|
||||
const coldMs = performance.now() - started;
|
||||
const coldConversions = conversions.mock.calls.length;
|
||||
expect(coldConversions).toBeLessThanOrEqual(2881);
|
||||
conversions.mockClear();
|
||||
const warmStarted = performance.now();
|
||||
for (let write = 0; write < 100; write++) for (let card = 0; card < 20; card++) {
|
||||
expect(nextStatusCardPolicyEligibility({ ...input,
|
||||
policy: { ...policy, activeHours: { ...policy.activeHours! } },
|
||||
now: new Date(now.getTime() + write * 1000),
|
||||
})).toEqual(new Date("2026-07-24T11:30:00Z"));
|
||||
}
|
||||
expect(constructions).toBeLessThanOrEqual(1);
|
||||
expect(conversions.mock.calls.length).toBeLessThanOrEqual(2000);
|
||||
process.stdout.write(JSON.stringify({ measurement: "status-card active-hours cost", coldMs, coldConversions, constructions,
|
||||
warmMs: performance.now() - warmStarted, warmConversions: conversions.mock.calls.length, evaluations: 2000 }) + "\n");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["America/New_York", "02:15", "03:30", "2026-03-08T06:50:00Z", "2026-03-08T07:00:00Z"],
|
||||
["America/New_York", "02:15", "02:45", "2026-03-08T06:50:00Z", "2026-03-09T06:15:00Z"],
|
||||
["America/New_York", "02:15", "02:45", "2026-03-07T07:45:00Z", "2026-03-09T06:15:00Z"],
|
||||
["America/New_York", "01:15", "01:45", "2026-11-01T05:50:00Z", "2026-11-01T06:15:00Z"],
|
||||
["America/New_York", "23:30", "01:30", "2026-11-01T05:40:00Z", "2026-11-01T06:00:00Z"],
|
||||
["Australia/Lord_Howe", "02:15", "02:45", "2026-10-03T15:20:00Z", "2026-10-03T15:30:00Z"],
|
||||
["Australia/Lord_Howe", "01:35", "01:45", "2026-04-04T14:50:00Z", "2026-04-04T15:05:00Z"],
|
||||
["Asia/Kathmandu", "09:00", "17:00", "2026-07-23T12:00:00Z", "2026-07-24T03:15:00Z"],
|
||||
["Pacific/Apia", "00:10", "00:20", "2011-12-30T09:50:00Z", "2011-12-30T10:10:00Z"],
|
||||
])("preserves the first real opening in %s %s-%s after %s", (timezone, start, end, time, expected) => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1, activeHours: { start, end, timezone } });
|
||||
const now = new Date(time);
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date(expected));
|
||||
// A later closed instant reuses the opening, while the exact opening itself
|
||||
// remains eligible without rounding it into the next minute or day.
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: new Date(now.getTime() + 1000) })).toEqual(new Date(expected));
|
||||
const opening = new Date(expected);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: opening })).toEqual(opening);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: new Date(opening.getTime() + 1) })).toEqual(new Date(opening.getTime() + 1));
|
||||
});
|
||||
|
||||
it("invalidates an upcoming opening for clock rollback and changed policy values", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } });
|
||||
const calculate = (time: string, activeHours = policy.activeHours!) => {
|
||||
const now = new Date(time);
|
||||
return nextStatusCardPolicyEligibility({ policy: { ...policy, activeHours }, now,
|
||||
lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 });
|
||||
};
|
||||
expect(calculate("2026-07-23T18:00:00Z")).toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T08:00:00Z")).toEqual(new Date("2026-07-23T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T17:00:00Z")).toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, start: "10:00" })).toEqual(new Date("2026-07-24T10:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, end: "19:00" })).toEqual(new Date("2026-07-23T18:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, timezone: "Asia/Tokyo" })).toEqual(new Date("2026-07-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("checks the precise instant before a cached minute opening for historical second offsets", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "10:00", timezone: "Africa/Monrovia" } });
|
||||
const now = new Date("1970-01-01T08:00:00Z");
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date("1970-01-01T09:45:00Z"));
|
||||
const inside = new Date("1970-01-01T09:44:45Z");
|
||||
expect(isWithinStatusCardActiveHours(policy, inside)).toBe(true);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: inside })).toEqual(inside);
|
||||
});
|
||||
|
||||
it("bounds formatter and upcoming-opening cache retention", () => {
|
||||
const now = new Date("2026-07-25T00:00:00Z");
|
||||
const timezonePolicy = (timezone: string) => statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone } });
|
||||
const zones = Intl.supportedValuesOf("timeZone").slice(0, 34);
|
||||
const first = timezonePolicy(zones[0]!);
|
||||
isWithinStatusCardActiveHours(first, now);
|
||||
for (const zone of zones.slice(1)) isWithinStatusCardActiveHours(timezonePolicy(zone), now);
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
const constructor = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
isWithinStatusCardActiveHours(first, now);
|
||||
expect(constructor).toHaveBeenCalledTimes(1);
|
||||
constructor.mockRestore();
|
||||
const time = (minute: number) => `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
||||
const calculate = (minute: number) => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: time(minute), end: time(minute + 1), timezone: "UTC" } });
|
||||
return nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 });
|
||||
};
|
||||
calculate(1);
|
||||
for (let minute = 2; minute <= 258; minute++) calculate(minute);
|
||||
const conversions = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts");
|
||||
expect(calculate(1)).toEqual(new Date("2026-07-25T00:01:00Z"));
|
||||
expect(conversions.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("selects full rebuilds for bounded drift rules and incremental otherwise", () => {
|
||||
const base = { hasDocument: true, changeCount: 2, queryVersion: 3, lastUpdateQueryVersion: 3, incrementalCount: 2, configurationChanged: false };
|
||||
expect(chooseStatusCardUpdateKind(base)).toBe("incremental");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
databaseWorkPending,
|
||||
documentRevisions,
|
||||
documents,
|
||||
heartbeatRuns,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
issues,
|
||||
statusCards,
|
||||
statusCardUpdates,
|
||||
subscribeDatabaseWork,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
defaultStatusCardRefreshPolicy,
|
||||
@@ -32,12 +35,14 @@ import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { statusCardService } from "../services/status-cards.js";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_STATUS_CARDS_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
@@ -79,8 +84,8 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-status-cards-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
if (!externalDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-status-cards-");
|
||||
db = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -100,6 +105,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
@@ -189,6 +195,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
.send({ title: "Launch health", titlePinned: true });
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body).toMatchObject({ title: "Launch health", titlePinned: true });
|
||||
expect(patched.body).not.toHaveProperty("nextEvalReason");
|
||||
|
||||
const scheduled = await request(app)
|
||||
.patch(`/api/status-cards/${created.body.id}`)
|
||||
@@ -634,6 +641,197 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
expect(refreshIssue).toMatchObject({ status: "cancelled" });
|
||||
});
|
||||
|
||||
it("keeps the current refresh writable after an older revision loses its claim", async () => {
|
||||
const company = await seedCompany();
|
||||
const summarizer = await seedSummarizer(company.id);
|
||||
const service = statusCardService(db);
|
||||
const realIssues = issueService(db);
|
||||
await realIssues.create(company.id, { title: "Watched blocker", status: "blocked", priority: "medium" });
|
||||
const card = await service.create(company.id, {
|
||||
interestPrompt: "Revision race", titlePinned: false,
|
||||
refreshPolicy: { ...defaultStatusCardRefreshPolicy, mode: "interval", intervalMinutes: 15 },
|
||||
}, { agentId: null, userId: "board-user" });
|
||||
await db.update(statusCards).set({
|
||||
state: "active", fingerprint: {},
|
||||
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
|
||||
}).where(eq(statusCards.id, card.id));
|
||||
|
||||
const oldGate = Promise.withResolvers<void>();
|
||||
const currentGate = Promise.withResolvers<void>();
|
||||
let oldIssueId: string | undefined;
|
||||
let currentIssueId: string | undefined;
|
||||
const blockedIssues = (which: "old" | "current") => ({
|
||||
...realIssues,
|
||||
update: async (...args: Parameters<typeof realIssues.update>) => {
|
||||
const result = await realIssues.update(...args);
|
||||
if (args[1].description) {
|
||||
if (which === "old") { oldIssueId = args[0]; await oldGate.promise; }
|
||||
else { currentIssueId = args[0]; await currentGate.promise; }
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const oldRefresh = statusCardService(db, { issuesSvc: blockedIssues("old") })
|
||||
.requestRefresh(card.id, { trigger: "interval" });
|
||||
let currentRefresh: ReturnType<typeof service.requestRefresh> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(oldIssueId).toBeTruthy());
|
||||
// Ordinary policy editing changes the card revision without changing the
|
||||
// watched fingerprint. Both refreshes have created their task before the
|
||||
// old loser cleans up; the current refresh has not yet claimed the card.
|
||||
await service.update((await service.getById(card.id))!, {
|
||||
refreshPolicy: defaultStatusCardRefreshPolicy,
|
||||
}, { agentId: null, userId: "board-user" });
|
||||
currentRefresh = statusCardService(db, { issuesSvc: blockedIssues("current") })
|
||||
.requestRefresh(card.id, { trigger: "manual" });
|
||||
await vi.waitFor(() => expect(currentIssueId).toBeTruthy());
|
||||
oldGate.resolve();
|
||||
await oldRefresh;
|
||||
currentGate.resolve();
|
||||
const current = await currentRefresh;
|
||||
const savedIssue = await realIssues.getById(current.generatingIssue!.id);
|
||||
expect(savedIssue).toMatchObject({ status: "todo" });
|
||||
expect(current.generatingIssue!.id).not.toBe(oldIssueId);
|
||||
const run = await seedRun(company.id, summarizer.id);
|
||||
await db.update(issues).set({ checkoutRunId: run.id }).where(eq(issues.id, savedIssue!.id));
|
||||
await service.writeSummary(card.id, {
|
||||
generationIssueId: savedIssue!.id, markdown: "Current revision summary",
|
||||
changeSummary: "Summarized the current revision", model: "test",
|
||||
}, { agentId: summarizer.id, runId: run.id });
|
||||
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null });
|
||||
expect(await realIssues.getById(oldIssueId!)).toMatchObject({ status: "cancelled" });
|
||||
} finally {
|
||||
oldGate.resolve(); currentGate.resolve();
|
||||
await Promise.allSettled([oldRefresh, ...(currentRefresh ? [currentRefresh] : [])]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
...(["creator", "deduplicated"] as const).flatMap(winnerKind =>
|
||||
(["active", "done", "cancelled"] as const).map(outcome => ({ winnerKind, outcome, requestTrigger: "interval" as const }))),
|
||||
...(["creator", "deduplicated"] as const).map(winnerKind => ({ winnerKind, outcome: "active" as const, requestTrigger: "manual" as const })),
|
||||
])("preserves a same-revision $winnerKind winner after summary and $outcome via $requestTrigger when its loser resumes", async ({ winnerKind, outcome, requestTrigger }) => {
|
||||
const company = await seedCompany();
|
||||
const summarizer = await seedSummarizer(company.id);
|
||||
const service = statusCardService(db);
|
||||
const realIssues = issueService(db);
|
||||
await realIssues.create(company.id, { title: "Watched blocker", status: "blocked", priority: "medium" });
|
||||
const card = await service.create(company.id, {
|
||||
interestPrompt: "Completed winner race", titlePinned: false,
|
||||
refreshPolicy: { ...defaultStatusCardRefreshPolicy, mode: "interval", intervalMinutes: 15 },
|
||||
}, { agentId: null, userId: "board-user" });
|
||||
await db.update(statusCards).set({ state: "active", fingerprint: {},
|
||||
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
|
||||
}).where(eq(statusCards.id, card.id));
|
||||
const gates = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
|
||||
const reached: string[] = [];
|
||||
const start = (index: number) => statusCardService(db, { issuesSvc: {
|
||||
...realIssues,
|
||||
update: async (...args: Parameters<typeof realIssues.update>) => {
|
||||
const result = await realIssues.update(...args);
|
||||
if (args[1].description) { reached[index] = args[0]; await gates[index]!.promise; }
|
||||
return result;
|
||||
},
|
||||
} }).requestRefresh(card.id, { trigger: requestTrigger });
|
||||
const creator = start(0);
|
||||
let deduplicated: ReturnType<typeof start> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(reached[0]).toBeTruthy());
|
||||
deduplicated = start(1);
|
||||
await vi.waitFor(() => expect(reached[1]).toBe(reached[0]));
|
||||
const winnerIndex = winnerKind === "creator" ? 0 : 1;
|
||||
gates[winnerIndex]!.resolve();
|
||||
const winner = await (winnerIndex === 0 ? creator : deduplicated);
|
||||
expect(winner.enqueued).toBe(true);
|
||||
expect(winner.alreadyGenerating).toBe(winnerKind === "deduplicated");
|
||||
const run = await seedRun(company.id, summarizer.id);
|
||||
await db.update(issues).set({ checkoutRunId: run.id }).where(eq(issues.id, reached[0]!));
|
||||
await service.writeSummary(card.id, {
|
||||
generationIssueId: reached[0]!, markdown: "Completed winner summary",
|
||||
changeSummary: "Summarized watched work", model: "test",
|
||||
}, { agentId: summarizer.id, runId: run.id });
|
||||
if (outcome !== "active") await realIssues.update(reached[0]!, { status: outcome });
|
||||
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null });
|
||||
const completed = await realIssues.getById(reached[0]!);
|
||||
expect(completed).toMatchObject({ status: outcome === "active" ? "todo" : outcome });
|
||||
|
||||
gates[1 - winnerIndex]!.resolve();
|
||||
const loser = await (winnerIndex === 0 ? deduplicated : creator);
|
||||
expect(loser).toMatchObject({ enqueued: false, alreadyGenerating: false, generatingIssue: null });
|
||||
expect(await realIssues.getById(reached[0]!)).toMatchObject({
|
||||
status: completed!.status, completedAt: completed!.completedAt, cancelledAt: completed!.cancelledAt,
|
||||
});
|
||||
const history = await service.listUpdates(card.id);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ generationIssueId: reached[0], status: "ok", finishedAt: expect.any(Date) });
|
||||
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null, state: "active" });
|
||||
if (requestTrigger === "manual") {
|
||||
const independent = await service.requestRefresh(card.id, { trigger: "manual" });
|
||||
expect(independent.enqueued).toBe(true);
|
||||
expect(independent.generatingIssue!.id).not.toBe(completed!.id);
|
||||
}
|
||||
} finally {
|
||||
gates.forEach(gate => gate.resolve());
|
||||
await Promise.allSettled([creator, ...(deduplicated ? [deduplicated] : [])]);
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels an unclaimed archived refresh with one database connection", async () => {
|
||||
const company = await seedCompany();
|
||||
await seedSummarizer(company.id);
|
||||
const single = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
||||
const service = statusCardService(single);
|
||||
const realIssues = issueService(single);
|
||||
let pending: Promise<unknown> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const signals: Array<{ event: string; pending: boolean; hints: string[] }> = [];
|
||||
try {
|
||||
const card = await service.create(company.id, {
|
||||
interestPrompt: "One connection cleanup", titlePinned: false, refreshPolicy: defaultStatusCardRefreshPolicy,
|
||||
}, { agentId: null, userId: "board-user" });
|
||||
await single.update(statusCards).set({ state: "active",
|
||||
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
|
||||
}).where(eq(statusCards.id, card.id));
|
||||
const racing = statusCardService(single, { issuesSvc: {
|
||||
...realIssues,
|
||||
update: async (...args: Parameters<typeof realIssues.update>) => {
|
||||
const result = await realIssues.update(...args);
|
||||
if (args[1].description) {
|
||||
await service.update((await service.getById(card.id))!, {
|
||||
archived: true,
|
||||
}, { agentId: null, userId: "board-user" });
|
||||
// Observe only the losing task's canonical cancellation, after the
|
||||
// independent archive transaction has settled.
|
||||
unsubscribe = subscribeDatabaseWork(single, DELIVERY_QUEUES.statusCardEvaluation, (event, hints) => {
|
||||
signals.push({ event, pending: databaseWorkPending(single, DELIVERY_QUEUES.statusCardEvaluation), hints: [...hints ?? []] });
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
} });
|
||||
let settled = false;
|
||||
let rejection: unknown;
|
||||
pending = racing.requestRefresh(card.id).then(() => { settled = true; }, error => {
|
||||
rejection = error; settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 2_000 });
|
||||
expect(rejection).toMatchObject({ status: 409 });
|
||||
expect(await single.select().from(issues).where(eq(issues.companyId, company.id))).toEqual([
|
||||
expect.objectContaining({ status: "cancelled" }),
|
||||
]);
|
||||
expect(await service.listUpdates(card.id)).toHaveLength(0);
|
||||
const cancellation = signals.filter(signal => signal.hints.includes(`changes:${company.id}`));
|
||||
expect(cancellation).toContainEqual(expect.objectContaining({ event: "intent", pending: true }));
|
||||
expect(cancellation.filter(signal => signal.event === "settled")).toEqual([
|
||||
expect.objectContaining({ event: "settled", pending: false }),
|
||||
]);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
await single.$client.end({ timeout: 1 });
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
|
||||
it("finalizes cancelled generation tasks as failed ledger entries", async () => {
|
||||
const company = await seedCompany();
|
||||
await enableStatusCards();
|
||||
@@ -1149,6 +1347,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
});
|
||||
expect(summaryWrite.status).toBe(200);
|
||||
expect(summaryWrite.body.card).toMatchObject({ state: "active", queryVersion: 1, generatingIssueId: null });
|
||||
expect(summaryWrite.body.card).not.toHaveProperty("nextEvalReason");
|
||||
expect(summaryWrite.body.document.latestBody).toContain("**Decide:**");
|
||||
expect(await db.select().from(statusCardUpdates).then((rows) => rows.find((row) => row.kind === "full"))).toMatchObject({ status: "ok", finishedAt: expect.any(Date), inputTokens: 5200, outputTokens: 980 });
|
||||
|
||||
|
||||
+5
-30
@@ -144,13 +144,12 @@ import {
|
||||
reconcileCodexLocalManagedHomesOnStartup,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
routineService,
|
||||
statusCardService,
|
||||
toolAccessService,
|
||||
workspaceOperationService,
|
||||
} from "./services/index.js";
|
||||
import { questionResponseDeliveryService } from "./services/question-response-delivery.js";
|
||||
import { deliverNativeQuestionResponse } from "./services/native-runtime/native-question-bridge.js";
|
||||
import { queueIssueAssignmentWakeup } from "./services/issue-assignment-wakeup.js";
|
||||
import { registerStatusCardEvaluationWork, statusCardEvaluationService } from "./services/status-card-evaluation.js";
|
||||
import { createSecretProposalsService } from "./services/secret-proposals.js";
|
||||
import { environmentRuntimeService } from "./services/environment-runtime.js";
|
||||
import { historicalCleanupVerificationService } from "./services/historical-cleanup-verification.js";
|
||||
@@ -1570,7 +1569,7 @@ async function startServerWithDatabaseTeardown(
|
||||
if (result.settled > 0) logger.info(result, "reconciled routine execution issue status");
|
||||
}).catch(() => logger.warn("routine execution status reconciliation deferred")));
|
||||
};
|
||||
const statusCards = statusCardService(db as any);
|
||||
const statusCardEvaluation = statusCardEvaluationService(db as any, heartbeat);
|
||||
const issues = issueService(db as any);
|
||||
const mergedPullRequestConfirmations = issueThreadInteractionService(db as any, {
|
||||
wakeup: heartbeat.wakeup,
|
||||
@@ -1947,6 +1946,8 @@ async function startServerWithDatabaseTeardown(
|
||||
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
|
||||
await step("join_request_retention", () => registerJoinRequestRetentionWork(deliveryWork, joinRequestRetentionService(db as any),
|
||||
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
|
||||
await step("status_card_evaluation", () => registerStatusCardEvaluationWork(deliveryWork, statusCardEvaluation,
|
||||
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
|
||||
await step("retention_sweep", runRetentionSweep);
|
||||
};
|
||||
|
||||
@@ -1977,33 +1978,7 @@ async function startServerWithDatabaseTeardown(
|
||||
scheduleGitHubConnectionContinuitySweep();
|
||||
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork((async () => {
|
||||
const experimental = await instanceSettingsService(db).getExperimental();
|
||||
if (experimental.enableStatusCards !== true) return;
|
||||
const result = await statusCards.tickDueStatusCards(new Date());
|
||||
await Promise.all(result.enqueued.map(async ({ cardId, generatingIssue }) => {
|
||||
try {
|
||||
await queueIssueAssignmentWakeup({
|
||||
heartbeat,
|
||||
issue: generatingIssue,
|
||||
reason: "status_card_update_assigned",
|
||||
mutation: "status_card.scheduler_update_requested",
|
||||
contextSource: "status_card_scheduler",
|
||||
requestedByActorType: "system",
|
||||
taskKey: `status-card:${cardId}`,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
await issues.update(generatingIssue.id, { status: "cancelled" });
|
||||
throw err;
|
||||
}
|
||||
}));
|
||||
if (result.evaluated > 0 || result.enqueued.length > 0) {
|
||||
logger.info({ evaluated: result.evaluated, enqueued: result.enqueued.length }, "status-card scheduler tick complete");
|
||||
}
|
||||
})().catch((err) => {
|
||||
logger.error({ err }, "status-card scheduler tick failed");
|
||||
}));
|
||||
|
||||
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../../external-agents/index.js";
|
||||
import { notifyNativeStatusReassessment } from "../../../services/native-status-reassessment-notifications.js";
|
||||
@@ -385,6 +386,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
));
|
||||
}
|
||||
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
|
||||
import { notifyDeferredExecutionWork } from "../../../services/deferred-execution-notifications.js";
|
||||
import { notifyRunDispatch } from "../../../services/run-dispatch-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "../../../services/external-conversation-settlement-notifications.js";
|
||||
@@ -612,6 +613,7 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
|
||||
},
|
||||
|
||||
async queueReviewParticipantRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const executionState = parseIssueExecutionState(issue.executionState);
|
||||
const wakeupRequest = await tx
|
||||
.insert(agentWakeupRequests)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../../external-agents/index.js";
|
||||
import { notifyNativeStatusReassessment } from "../../../services/native-status-reassessment-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
|
||||
@@ -55,6 +56,7 @@ export type QueuedCommentQueuePostgresAdapterDeps = {
|
||||
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps): QueuedCommentQueueTransaction {
|
||||
return {
|
||||
async updateCommentBody({ issueId, commentId, body, updatedAt }) {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const updated = await tx
|
||||
.update(issueComments)
|
||||
.set({ body, updatedAt })
|
||||
@@ -65,6 +67,7 @@ function buildTransaction(tx: Db, companyId: string, issueId: string, deps: Queu
|
||||
},
|
||||
|
||||
async touchIssueUpdatedAt({ issueId, updatedAt }) {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(issues).set({ updatedAt }).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
|
||||
},
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../services/status-card-notifications.js";
|
||||
import { removePluginWorkspace } from "../services/plugin-workspaces.js";
|
||||
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
|
||||
import { canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition } from "../services/authorization.js";
|
||||
@@ -1245,7 +1246,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
await runtimeLeases.release({ executionWorkspaceId: existing.id, force: true });
|
||||
|
||||
if (existing.mode === "shared_workspace") {
|
||||
await db
|
||||
await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, existing.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionWorkspaceId: null,
|
||||
@@ -1257,6 +1260,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
eq(issues.executionWorkspaceId, existing.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,12 @@ import { accessService, heartbeatService, instanceSettingsService, issueService,
|
||||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
|
||||
// Deadline provenance belongs to scheduling, not the public card contract.
|
||||
function publicCard<T extends { nextEvalReason?: unknown }>(card: T) {
|
||||
const { nextEvalReason: _reason, ...result } = card;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
||||
const router = Router();
|
||||
const access = accessService(db);
|
||||
@@ -144,9 +150,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertStatusCardsEnabled();
|
||||
const query = listStatusCardsQuerySchema.parse(req.query);
|
||||
res.json(await service.list(companyId, query.archived, {
|
||||
res.json((await service.list(companyId, query.archived, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
})).map(publicCard));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => {
|
||||
@@ -162,7 +168,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
try {
|
||||
const compile = await enqueueCompile(req, card.id);
|
||||
await logMutation(req, companyId, "status_card.created", card.id, { state: card.state });
|
||||
res.status(201).json(compile.card);
|
||||
res.status(201).json(publicCard(compile.card));
|
||||
} catch (error) {
|
||||
await service.remove(card.id);
|
||||
throw error;
|
||||
@@ -173,9 +179,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
await assertStatusCardsEnabled();
|
||||
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
|
||||
if (!card) return;
|
||||
res.json(await service.hydrate(card, {
|
||||
res.json(publicCard(await service.hydrate(card, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
})));
|
||||
});
|
||||
|
||||
router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => {
|
||||
@@ -197,7 +203,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
fields: Object.keys(req.body),
|
||||
archived: Boolean(updated.archivedAt),
|
||||
});
|
||||
res.json(compile?.card ?? restore?.card ?? updated);
|
||||
res.json(publicCard(compile?.card ?? restore?.card ?? updated));
|
||||
});
|
||||
|
||||
router.delete("/status-cards/:id", async (req, res) => {
|
||||
@@ -234,7 +240,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
generatingIssueId: result.generatingIssue.id,
|
||||
alreadyGenerating: result.alreadyGenerating,
|
||||
});
|
||||
res.status(result.alreadyGenerating ? 200 : 202).json(result);
|
||||
res.status(result.alreadyGenerating ? 200 : 202).json({ ...result, card: publicCard(result.card) });
|
||||
});
|
||||
|
||||
router.post("/status-cards/:id/refresh", validate(refreshStatusCardSchema), async (req, res) => {
|
||||
@@ -249,7 +255,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
alreadyGenerating: result.alreadyGenerating,
|
||||
enqueued: result.enqueued,
|
||||
});
|
||||
res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json(result);
|
||||
res.status(result.enqueued && !result.alreadyGenerating ? 202 : 200).json({ ...result, card: publicCard(result.card) });
|
||||
});
|
||||
|
||||
router.get("/status-cards/:id/dry-run", async (req, res) => {
|
||||
@@ -289,7 +295,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
generationIssueId: req.body.generationIssueId,
|
||||
changeSummary: req.body.changeSummary,
|
||||
});
|
||||
res.json(updated);
|
||||
res.json(publicCard(updated));
|
||||
});
|
||||
|
||||
router.put("/status-cards/:id/summary", validate(writeStatusCardSummarySchema), async (req, res) => {
|
||||
@@ -309,7 +315,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
documentId: result.document.id,
|
||||
changeSummary: req.body.changeSummary,
|
||||
});
|
||||
res.json(result);
|
||||
res.json({ ...result, card: publicCard(result.card) });
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
@@ -747,6 +748,7 @@ export function accessService(db: Db) {
|
||||
await notifyDeferredExecutionEligibility(tx, { companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const resetInProgress = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
@@ -270,6 +271,7 @@ export async function prepareConversationTurn(
|
||||
context.conversationReplayThroughCommentId = replayThrough?.id ?? commentId;
|
||||
}
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, issue.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await tx
|
||||
@@ -390,6 +392,7 @@ export async function settleConversationTurn(
|
||||
issue.conversationState === conversationState
|
||||
)
|
||||
return true;
|
||||
await notifyStatusCardIssueChange(tx, issue.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
if (status === "in_review") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
@@ -38,6 +39,7 @@ export async function deleteAgentDependencies(tx: Db, companyId: string, id: str
|
||||
await issueThreadInteractionService(tx).cancelPendingForDeletedAddressee(companyId, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
await tx
|
||||
.update(issues)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
@@ -325,6 +326,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
|
||||
await notifyRunDispatch(db, { companyId: policy.companyId });
|
||||
await notifyDeferredExecutionEligibility(db, { companyId: policy.companyId });
|
||||
await notifyCompanyRetentionWork(db, policy.companyId);
|
||||
await notifyStatusCardIssueChange(db, policy.companyId);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.issueMonitors);
|
||||
@@ -389,6 +391,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
|
||||
await notifyRunDispatch(db, { companyId: policy.companyId });
|
||||
await notifyDeferredExecutionEligibility(db, { companyId: policy.companyId });
|
||||
await notifyCompanyRetentionWork(db, policy.companyId);
|
||||
await notifyStatusCardIssueChange(db, policy.companyId);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.issueMonitors);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../../status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "../../issue-recovery-notifications.js";
|
||||
import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js";
|
||||
|
||||
@@ -84,6 +85,7 @@ export function createTelegramAttachmentFailure(db: Db, options: TelegramAttachm
|
||||
// Paperclip-visible failure. The message link stays authoritative for
|
||||
// exact provider redelivery, while no agent wakeup is enqueued for an
|
||||
// empty turn that contains no usable bytes.
|
||||
await notifyStatusCardIssueChange(tx, input.endpoint.companyId);
|
||||
await tx
|
||||
.update(issueComments)
|
||||
.set({ body: visibleFailure, updatedAt: new Date() })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
@@ -340,6 +341,7 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardIssueChange(tx, id);
|
||||
await notifyCompanyRetentionWork(tx, id);
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
||||
@@ -498,6 +500,7 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardIssueChange(tx, id);
|
||||
await notifyCompanyRetentionWork(tx, id);
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
||||
@@ -79,6 +80,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardEvaluation(tx, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, id);
|
||||
await notifyToolReviewChange(tx, id);
|
||||
await agentObservabilityService(tx as unknown as Db).removeForCompanyDeletion(id);
|
||||
|
||||
@@ -207,14 +207,20 @@ function priorityRank(priority: string | null | undefined) {
|
||||
return index >= 0 ? index : ISSUE_PRIORITIES.length;
|
||||
}
|
||||
|
||||
function updatedWithinStart(value: string | undefined, now = new Date()): Date | null {
|
||||
/** Duration used by saved search membership and its deadline consumers. */
|
||||
export function companySearchUpdatedWithinMs(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d+)(h|d|w|m)$/.exec(value);
|
||||
if (!match) return null;
|
||||
const amount = Number.parseInt(match[1]!, 10);
|
||||
const unit = match[2];
|
||||
const hours = unit === "h" ? amount : unit === "d" ? amount * 24 : unit === "w" ? amount * 24 * 7 : amount * 24 * 30;
|
||||
return new Date(now.getTime() - hours * 60 * 60 * 1000);
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function updatedWithinStart(value: string | undefined, now = new Date()): Date | null {
|
||||
const duration = companySearchUpdatedWithinMs(value);
|
||||
return duration === null ? null : new Date(now.getTime() - duration);
|
||||
}
|
||||
|
||||
function issueOnlyFiltersActive(query: CompanySearchQuery) {
|
||||
@@ -542,7 +548,9 @@ export function companySearchService(db: Db) {
|
||||
search: async (
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean>; onNextMembershipChange?: (at: Date | null) => void;
|
||||
/** Restrict result kinds before pagination; retain the requested text-match scope. */
|
||||
issueResultsOnly?: boolean },
|
||||
): Promise<CompanySearchResponse> => {
|
||||
const taskSearch = parseTaskSearch(query.q);
|
||||
const normalizedQuery = taskSearch.normalizedQuery;
|
||||
@@ -552,7 +560,8 @@ export function companySearchService(db: Db) {
|
||||
const sort = query.sort;
|
||||
const limit = query.limit;
|
||||
const offset = query.offset;
|
||||
if (!hasSearchText && !issueOnlyFiltersActive(query)) {
|
||||
if ((!hasSearchText && !issueOnlyFiltersActive(query)) || (options?.issueResultsOnly && !scopeIncludesIssues(scope))) {
|
||||
options?.onNextMembershipChange?.(null);
|
||||
return {
|
||||
query: query.q,
|
||||
normalizedQuery,
|
||||
@@ -721,6 +730,13 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
const membershipWindow = companySearchUpdatedWithinMs(query.updatedWithin);
|
||||
if (options?.onNextMembershipChange && membershipWindow !== null && scopeIncludesIssues(scope)) {
|
||||
// Includes rows outside the visible page: their expiry can move an
|
||||
// offset/priority-ranked result into the watched set without a write.
|
||||
branches.push(sql`SELECT 'membership-expiry' AS kind, min(m.updated_at)::text AS value,
|
||||
0::int AS count ${countTail} FROM matched m ${branchWhere([scopeCond, ...facetsAll])}`);
|
||||
}
|
||||
const resultRows = await db.execute(sql`
|
||||
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters), options?.issueReadCondition)}
|
||||
${sql.join(branches, sql` UNION ALL `)}
|
||||
@@ -732,6 +748,7 @@ export function companySearchService(db: Db) {
|
||||
totals: { current: 0, unfiltered: 0, omit: {} },
|
||||
};
|
||||
const issueRowsRaw: IssueSearchRow[] = [];
|
||||
let nextMembershipChange: Date | null = null;
|
||||
for (const row of resultRows) {
|
||||
if (row.kind === "result") {
|
||||
issueRowsRaw.push({
|
||||
@@ -756,6 +773,9 @@ export function companySearchService(db: Db) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "membership-expiry" && row.value && membershipWindow !== null) {
|
||||
nextMembershipChange = new Date(new Date(row.value).getTime() + membershipWindow + 1);
|
||||
}
|
||||
const count = Number(row.count ?? 0);
|
||||
if (row.kind === "type:issue") aggregates.typeCounts.issue = count;
|
||||
else if (row.kind === "type:comment") aggregates.typeCounts.comment = count;
|
||||
@@ -776,6 +796,7 @@ export function companySearchService(db: Db) {
|
||||
aggregates.totals.omit[row.kind.slice("total:omit:".length) as CompanySearchIssueFilterKey] = count;
|
||||
}
|
||||
}
|
||||
options?.onNextMembershipChange?.(nextMembershipChange);
|
||||
return { rows: await enrichIssueSnippets(issueRowsRaw), aggregates };
|
||||
}
|
||||
|
||||
@@ -875,7 +896,7 @@ export function companySearchService(db: Db) {
|
||||
const projectReadCondition = options?.projectReadCondition ?? sql<boolean>`true`;
|
||||
|
||||
async function fetchAgentRows() {
|
||||
if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
|
||||
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
|
||||
return db
|
||||
.select({
|
||||
id: agents.id,
|
||||
@@ -892,7 +913,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
async function fetchProjectRows() {
|
||||
if (!hasSearchText || !scopeIncludesProjects(scope) || hasIssueOnlyFilters) return [];
|
||||
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesProjects(scope) || hasIssueOnlyFilters) return [];
|
||||
return db
|
||||
.select({
|
||||
id: projects.id,
|
||||
@@ -908,7 +929,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
async function countArtifacts(filters: CompanySearchQuery = query) {
|
||||
if (!hasSearchText) return 0;
|
||||
if (options?.issueResultsOnly || !hasSearchText) return 0;
|
||||
const artifactIssueFilters = issueFilterConditions(companyId, filters);
|
||||
const artifactIssueConditions = [
|
||||
eq(issues.companyId, companyId),
|
||||
@@ -976,7 +997,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
async function countAgents(filters: CompanySearchQuery = query) {
|
||||
if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0;
|
||||
if (options?.issueResultsOnly || !hasSearchText || issueOnlyFiltersActive(filters)) return 0;
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(agents)
|
||||
@@ -985,7 +1006,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
async function countProjects(filters: CompanySearchQuery = query) {
|
||||
if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0;
|
||||
if (options?.issueResultsOnly || !hasSearchText || issueOnlyFiltersActive(filters)) return 0;
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
@@ -994,7 +1015,7 @@ export function companySearchService(db: Db) {
|
||||
}
|
||||
|
||||
async function fetchArtifactRows() {
|
||||
if (!hasSearchText || !scopeIncludesArtifacts(scope)) return [];
|
||||
if (options?.issueResultsOnly || !hasSearchText || !scopeIncludesArtifacts(scope)) return [];
|
||||
const result = await companyArtifactsService(db).list(companyId, {
|
||||
q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH),
|
||||
limit: Math.min(fetchLimit, COMPANY_ARTIFACTS_MAX_LIMIT),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { skillExecutionContent } from "./skill-execution-content.js";
|
||||
import { notifySkillVersionRetention } from "./skill-version-retention-notifications.js";
|
||||
import { managedSkillContent, type SkillContentPublication } from "./managed-skill-content.js";
|
||||
@@ -7345,6 +7346,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
for (const row of rows) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ hiddenAt: now, updatedAt: now })
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DELIVERY_QUEUES = {
|
||||
staleIssueLocks: "stale-issue-locks",
|
||||
scheduledRetryDispatch: "scheduled-retry-dispatch",
|
||||
queuedRunDispatch: "queued-run-dispatch",
|
||||
statusCardEvaluation: "status-card-evaluation",
|
||||
runEvidenceMigration: "run-evidence-migration",
|
||||
providerTraceRetention: "provider-trace-retention",
|
||||
nativeRunnerEvidenceRetention: "native-runner-evidence-retention",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
@@ -239,6 +240,7 @@ export function documentService(db: Db) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
await notifyStatusCardIssueChange(tx, issue.companyId);
|
||||
const now = new Date();
|
||||
const existing = await tx
|
||||
.select({
|
||||
@@ -597,6 +599,7 @@ export function documentService(db: Db) {
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
for (const companyId of new Set(rows.map(row => row.companyId))) {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await notifyIssueRecovery(tx, { companyId });
|
||||
}
|
||||
await insertRowsInChunks(tx, documents, documentRows);
|
||||
@@ -670,6 +673,7 @@ export function documentService(db: Db) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
await notifyStatusCardIssueChange(tx, existing.companyId);
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
@@ -829,6 +833,7 @@ export function documentService(db: Db) {
|
||||
|
||||
await notifyIssueRecovery(tx, { companyId: existing.companyId });
|
||||
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
|
||||
await notifyStatusCardIssueChange(tx, existing.companyId);
|
||||
await tx.delete(documents).where(eq(documents.id, existing.id));
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork, type DeliveryQueue } from "./delivery-work-notifications.js";
|
||||
|
||||
export type ExecutionWorkScope = { companyId: string; agentId?: string; runId?: string };
|
||||
export type ExecutionWorkScope = { companyId: string; agentId?: string; runId?: string; issueId?: string };
|
||||
|
||||
export async function notifyExecutionRunWork(transaction: object, scope?: ExecutionWorkScope) {
|
||||
if (scope) {
|
||||
await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
await notifyDeferredExecutionEligibility(transaction, scope);
|
||||
}
|
||||
@@ -23,6 +25,7 @@ export function writeExecutionControl<T>(
|
||||
scope: ExecutionWorkScope | undefined,
|
||||
): Promise<T> {
|
||||
return db.transaction(async transaction => {
|
||||
if (scope) await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
if (scope) await notifyDeferredExecutionEligibility(transaction, scope);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.issueMonitors);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { effectiveProjectWorkspacePolicy } from "./project-workspace-provider.js";
|
||||
import { inspectPluginWorkspace, removePluginWorkspace, restorePluginWorkspace, type WorkspaceWorkers } from "./plugin-workspaces.js";
|
||||
import { acquireExecutionWorkspaceLifecycleLock, withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifecycle-lock.js";
|
||||
@@ -1590,6 +1591,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
await validateSelection(input, tx);
|
||||
const intent: TaskWorkspaceIntent = { version: 1, request: { key: input.requestKey, expectedBindingRevision: input.expectedBindingRevision }, selection: input.selection, source: input.source ?? "explicit" };
|
||||
const nextRevision = task.workspaceBindingRevision + 1;
|
||||
await notifyStatusCardIssueChange(tx, task.companyId);
|
||||
await tx.update(issues).set({ workspaceBindingRevision: nextRevision,
|
||||
workspacePendingSelection: { version: 1, requestKey: input.requestKey,
|
||||
expectedBindingRevision: nextRevision, intent }, updatedAt: new Date() }).where(eq(issues.id, task.id));
|
||||
@@ -1633,6 +1635,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
}
|
||||
}
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, task.companyId);
|
||||
await tx.update(issues).set({ ...patch, assigneeAdapterOverrides, workspaceSelection: pending.intent, workspacePendingSelection: null,
|
||||
workspaceBindingRevision: task.workspaceBindingRevision + 1, updatedAt: new Date() }).where(eq(issues.id, task.id));
|
||||
return true;
|
||||
@@ -1651,6 +1654,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
const pendingAfterFirstBinding = !task.executionWorkspaceId && pending?.expectedBindingRevision === task.workspaceBindingRevision
|
||||
? { ...pending, expectedBindingRevision: nextRevision }
|
||||
: pending;
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const [bound] = await tx.update(issues).set({ ...patch, executionWorkspaceId: workspaceId,
|
||||
workspaceBindingRevision: nextRevision, workspacePendingSelection: pendingAfterFirstBinding, updatedAt: new Date() })
|
||||
.where(eq(issues.id, issueId)).returning();
|
||||
@@ -2526,13 +2530,16 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
forceWorktreeRemoval: false,
|
||||
});
|
||||
if (cleanup.cleaned && workspace.mode === "shared_workspace") {
|
||||
await db
|
||||
await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, workspace.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ executionWorkspaceId: null, updatedAt: now() })
|
||||
.where(and(
|
||||
eq(issues.companyId, workspace.companyId),
|
||||
eq(issues.executionWorkspaceId, workspace.id),
|
||||
));
|
||||
});
|
||||
}
|
||||
const cleanupReason = [ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON, ...cleanup.warnings].join(" | ");
|
||||
if (!cleanup.cleaned || cleanup.warnings.length > 0) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { PersistedExecutionWaitRefusal, writeDeferredExecutionWait } from "../deferred-execution-wait.js";
|
||||
import { notifyDeferredExecutionWork } from "../deferred-execution-notifications.js";
|
||||
import type { DeferredReceiptRecoveryOptions } from "../deferred-execution-notifications.js";
|
||||
@@ -1271,6 +1272,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
}
|
||||
async function bindClaimedIssueExecution(tx: Db, ownsIssue: boolean, claimedRun: typeof heartbeatRuns.$inferSelect | null | undefined) {
|
||||
if (!claimedRun || !issueId || !ownsIssue) return;
|
||||
await notifyStatusCardIssueChange(tx, run.companyId);
|
||||
await tx.update(issues).set({
|
||||
executionRunId: claimedRun.id,
|
||||
executionAgentNameKey: normalizeAgentNameKey(agent.name),
|
||||
@@ -3770,6 +3772,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
.from(agents)
|
||||
.where(eq(agents.id, legacyRun.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
await notifyStatusCardIssueChange(tx, issue.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyScheduledRetryDispatch } from "../run-dispatch-notifications.js";
|
||||
import { deriveTaskKeyWithHeartbeatFallback } from "./run-state.js";
|
||||
import { infrastructureRetryDelayMs } from "../system-recovery-state.js";
|
||||
@@ -1180,6 +1181,7 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen
|
||||
}
|
||||
|
||||
if (issueId) {
|
||||
await notifyStatusCardIssueChange(tx, run.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
||||
import { notifyIssueReviewState } from "../issue-review-state-notifications.js";
|
||||
import { insertIssueComment } from "../issue-comment-writes.js";
|
||||
@@ -595,6 +596,7 @@ export function createHeartbeatScheduling(
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardIssueChange(tx, claimed.companyId);
|
||||
const released = await tx.update(issues)
|
||||
.set({ monitorWakeRequestedAt: null, updatedAt: input.now })
|
||||
.where(issueMonitorClaimCondition(claimed)).returning({ id: issues.id });
|
||||
@@ -628,6 +630,7 @@ export function createHeartbeatScheduling(
|
||||
// failed, and never clobber a monitor that was replaced in the meantime.
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardIssueChange(tx, claimed.companyId);
|
||||
await tx.update(issues).set({ monitorWakeRequestedAt: null, updatedAt: input.now })
|
||||
.where(issueMonitorClaimCondition(claimed));
|
||||
});
|
||||
@@ -827,6 +830,7 @@ export function createHeartbeatScheduling(
|
||||
}
|
||||
|
||||
const consumed = await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, claimed.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
return tx.update(issues)
|
||||
.set({
|
||||
@@ -977,6 +981,7 @@ export function createHeartbeatScheduling(
|
||||
|
||||
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const claimed = await db.transaction(async (tx) => {
|
||||
await notifyStatusCardIssueChange(tx, issue.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
const [updated] = await tx
|
||||
.update(issues)
|
||||
@@ -1050,6 +1055,7 @@ export function createHeartbeatScheduling(
|
||||
for (const due of dueMonitors) {
|
||||
if (!canContinue()) break;
|
||||
const claimed = await db.transaction(async (tx) => {
|
||||
await notifyStatusCardIssueChange(tx, due.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
const [updated] = await tx
|
||||
.update(issues)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifySkillVersionRetentionReconstruction } from "./skill-version-retention-notifications.js";
|
||||
@@ -629,6 +630,7 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService
|
||||
&& toExperimentalView(nextExperimental).enableAgentChat;
|
||||
const now = new Date();
|
||||
const [updated] = await db.transaction(async tx => {
|
||||
if (patch.enableStatusCards !== undefined) await notifyStatusCardEvaluation(tx);
|
||||
if (chatBecameEnabled) await notifyDeferredExecutionEligibility(tx);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import type { PgInsertValue } from "drizzle-orm/pg-core";
|
||||
import { issueComments, type Db } from "@paperclipai/db";
|
||||
@@ -7,6 +8,7 @@ type WriteDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export function insertIssueComment(db: WriteDb, input: PgInsertValue<typeof issueComments> & { companyId: string }) {
|
||||
return db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await notifyIssueRecovery(tx, { companyId: input.companyId,
|
||||
issueId: typeof input.issueId === "string" ? input.issueId : undefined });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, `company:${input.companyId}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { PgColumn } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -127,6 +128,7 @@ export async function rekeyCompanyIssueIdentifiers(
|
||||
// overload, which returns NULL for a non-pattern argument.
|
||||
const tailStart = sql`${fromPrefix.length + 1}::int`;
|
||||
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const rekeyedIssues = await tx
|
||||
.update(issues)
|
||||
.set({ identifier: sql`${toPrefix} || substring(${issues.identifier} from ${tailStart})` })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
@@ -5,6 +6,7 @@ import { notifyNativeReviewResponse } from "./native-review-response-notificatio
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
|
||||
export async function notifyIssueReviewState(tx: object, scope: { companyId: string; issueId?: string; runId?: string; agentId?: string }) {
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
await notifyRunDispatch(tx, scope);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyExternalConversationSettlement(tx, scope);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { notifyIssueReviewState } from "./issue-review-state-notifications.js";
|
||||
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
|
||||
@@ -376,7 +377,7 @@ type ResolvedInteractionResult = {
|
||||
};
|
||||
|
||||
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
||||
type IssueTouchDb = Pick<Db, "update">;
|
||||
type IssueTouchDb = Pick<Db, "update" | "transaction">;
|
||||
|
||||
function isNativeCompletionReview(
|
||||
row: Pick<IssueThreadInteractionRow, "kind" | "payload">,
|
||||
@@ -801,11 +802,11 @@ function hydrateInteraction(
|
||||
}
|
||||
}
|
||||
|
||||
async function touchIssue(db: IssueTouchDb, issueId: string) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(issues.id, issueId));
|
||||
async function touchIssue(db: IssueTouchDb, issueId: string, companyId: string) {
|
||||
await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, issueId));
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalIssueStatus(status: string) {
|
||||
@@ -2013,7 +2014,7 @@ async function expireStaleRequestConfirmationTarget(
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
await touchIssue(tx, args.row.issueId);
|
||||
await touchIssue(tx, args.row.issueId, args.row.companyId);
|
||||
const expired = hydrateInteraction(updated);
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx, expired);
|
||||
return expired;
|
||||
@@ -2468,7 +2469,7 @@ export function issueThreadInteractionService(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
await touchIssue(tx, args.issue.id);
|
||||
await touchIssue(tx, args.issue.id, args.issue.companyId);
|
||||
}
|
||||
|
||||
// Snapshot the continuation target only after every lifecycle mutation
|
||||
@@ -2682,7 +2683,7 @@ export function issueThreadInteractionService(
|
||||
tx,
|
||||
);
|
||||
} else {
|
||||
await touchIssue(tx, args.issue.id);
|
||||
await touchIssue(tx, args.issue.id, args.issue.companyId);
|
||||
}
|
||||
await enqueueTerminalIssueInteractionChatPublications(
|
||||
tx as unknown as Db,
|
||||
@@ -2864,7 +2865,7 @@ export function issueThreadInteractionService(
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(previous));
|
||||
}
|
||||
}
|
||||
await touchIssue(tx, issue.id);
|
||||
await touchIssue(tx, issue.id, issue.companyId);
|
||||
inserted = true;
|
||||
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
||||
@@ -2908,7 +2909,7 @@ export function issueThreadInteractionService(
|
||||
)
|
||||
.returning();
|
||||
if (!updated) throw interactionAlreadyResolvedError();
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
return hydrateInteraction(updated) as ConnectionIntentInteraction;
|
||||
},
|
||||
resolveConnectionIntent: async (
|
||||
@@ -2972,7 +2973,7 @@ export function issueThreadInteractionService(
|
||||
return row;
|
||||
});
|
||||
if (!updated) throw interactionAlreadyResolvedError();
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const interaction = hydrateInteraction(
|
||||
updated,
|
||||
) as ConnectionIntentInteraction;
|
||||
@@ -3351,7 +3352,7 @@ export function issueThreadInteractionService(
|
||||
})
|
||||
.where(eq(issueThreadInteractions.id, current.id))
|
||||
.returning();
|
||||
await touchIssue(tx, issue.id);
|
||||
await touchIssue(tx, issue.id, issue.companyId);
|
||||
return row;
|
||||
});
|
||||
return hydrateInteraction(updated);
|
||||
@@ -3414,10 +3415,8 @@ export function issueThreadInteractionService(
|
||||
if (updated) cancelled.push(updated);
|
||||
}
|
||||
|
||||
for (const issueId of new Set(
|
||||
cancelled.map((interaction) => interaction.issueId),
|
||||
)) {
|
||||
await touchIssue(db, issueId);
|
||||
for (const [issueId, companyId] of new Map(cancelled.map(interaction => [interaction.issueId, interaction.companyId]))) {
|
||||
await touchIssue(db, issueId, companyId);
|
||||
}
|
||||
await emitResolvedInteractionsTelemetry(db, cancelled);
|
||||
return cancelled;
|
||||
@@ -3506,10 +3505,8 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
for (const issueId of new Set(
|
||||
expired.map((interaction) => interaction.issueId),
|
||||
)) {
|
||||
await touchIssue(db, issueId);
|
||||
for (const [issueId, companyId] of new Map(expired.map(interaction => [interaction.issueId, interaction.companyId]))) {
|
||||
await touchIssue(db, issueId, companyId);
|
||||
}
|
||||
await emitResolvedInteractionsTelemetry(db, expired);
|
||||
}
|
||||
@@ -4052,7 +4049,7 @@ export function issueThreadInteractionService(
|
||||
return interaction;
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
if (superseded.length > 0) {
|
||||
await emitResolvedInteractionsTelemetry(
|
||||
db,
|
||||
@@ -4357,7 +4354,7 @@ export function issueThreadInteractionService(
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.returning();
|
||||
|
||||
await touchIssue(tx, issue.id);
|
||||
await touchIssue(tx, issue.id, issue.companyId);
|
||||
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
||||
current.status = updated.status;
|
||||
@@ -4549,7 +4546,7 @@ export function issueThreadInteractionService(
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(tx, issue.id);
|
||||
await touchIssue(tx, issue.id, issue.companyId);
|
||||
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
||||
if (complete) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
||||
return {
|
||||
@@ -4622,7 +4619,7 @@ export function issueThreadInteractionService(
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const rejected = hydrateInteraction(updated);
|
||||
await emitInteractionResolvedTelemetry(db, rejected);
|
||||
return rejected;
|
||||
@@ -4720,7 +4717,7 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
await emitResolvedInteractionsTelemetry(db, expired);
|
||||
}
|
||||
return expired;
|
||||
@@ -4952,7 +4949,7 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
await emitResolvedInteractionsTelemetry(db, expired);
|
||||
}
|
||||
return expired;
|
||||
@@ -5049,7 +5046,7 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
await emitResolvedInteractionsTelemetry(db, expired);
|
||||
}
|
||||
return expired;
|
||||
@@ -5189,7 +5186,7 @@ export function issueThreadInteractionService(
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
if (expired.length > 0) {
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
await emitResolvedInteractionsTelemetry(db, expired);
|
||||
}
|
||||
return expired;
|
||||
@@ -5294,7 +5291,7 @@ export function issueThreadInteractionService(
|
||||
return row;
|
||||
});
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const withdrawn = hydrateInteraction(updated);
|
||||
await emitInteractionResolvedTelemetry(db, withdrawn);
|
||||
return withdrawn;
|
||||
@@ -5417,7 +5414,7 @@ export function issueThreadInteractionService(
|
||||
return row;
|
||||
});
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const answered = hydrateInteraction(updated);
|
||||
await emitInteractionResolvedTelemetry(db, answered);
|
||||
return answered;
|
||||
@@ -5553,7 +5550,7 @@ export function issueThreadInteractionService(
|
||||
return row;
|
||||
});
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const skipped = hydrateInteraction(updated);
|
||||
await emitInteractionResolvedTelemetry(db, skipped);
|
||||
return skipped;
|
||||
@@ -5630,7 +5627,7 @@ export function issueThreadInteractionService(
|
||||
return row;
|
||||
});
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
await touchIssue(db, issue.id, issue.companyId);
|
||||
const cancelled = hydrateInteraction(updated);
|
||||
await emitInteractionResolvedTelemetry(db, cancelled);
|
||||
return cancelled;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
import { setIssueTitleSchema, type SetIssueTitle } from "@paperclipai/shared";
|
||||
@@ -76,6 +77,7 @@ export async function setIssueTitle(
|
||||
|| (issue.title === title && !issue.titleNeedsGeneration)) {
|
||||
return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null };
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate);
|
||||
await issueReferenceService(tx).syncIssue(issueId, tx);
|
||||
const { publication } = await persistActivity(tx, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
@@ -1018,6 +1019,7 @@ export function issueTreeControlService(db: Db) {
|
||||
await notifyChatResponseAvailability(tx, { companyId, issueId });
|
||||
if (status === "blocked" || status === "in_review") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const rows = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
@@ -7735,6 +7736,7 @@ export function issueService(db: Db) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await notifyStatusCardIssueChange(tx, lockedIssue.companyId);
|
||||
const adopted = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
@@ -7788,13 +7790,14 @@ export function issueService(db: Db) {
|
||||
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
|
||||
);
|
||||
const actorRun = await tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.select({ status: heartbeatRuns.status, companyId: heartbeatRuns.companyId })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, input.actorRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status))
|
||||
return null;
|
||||
|
||||
await notifyStatusCardIssueChange(tx, actorRun.companyId);
|
||||
const now = new Date();
|
||||
const adopted = await tx
|
||||
.update(issues)
|
||||
@@ -10617,6 +10620,7 @@ export function issueService(db: Db) {
|
||||
await notifyRunDispatch(tx, { companyId: values.companyId });
|
||||
await notifyDeferredExecutionEligibility(tx, { companyId: values.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
@@ -10901,6 +10905,7 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await insertRowsInChunks(tx, issues, issueRows);
|
||||
await insertRowsInChunks(tx, issueLabels, labelRows);
|
||||
});
|
||||
@@ -10917,6 +10922,7 @@ export function issueService(db: Db) {
|
||||
const censorUsernameInLogs = (await instanceSettings.getGeneral())
|
||||
.censorUsernameInLogs;
|
||||
await db.transaction(async (tx) => {
|
||||
for (const companyId of new Set(rows.map(row => row.companyId))) await notifyStatusCardIssueChange(tx, companyId);
|
||||
const commentRows = rows.map((row) => {
|
||||
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
|
||||
return {
|
||||
@@ -11497,6 +11503,7 @@ export function issueService(db: Db) {
|
||||
(blockedByIssueIds !== undefined || issueData.unblockDescriptor !== undefined)))) {
|
||||
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, receiptExisting.companyId);
|
||||
if ((patch.status !== undefined && patch.status !== receiptExisting.status)
|
||||
|| patch.completedAt !== undefined || patch.cancelledAt !== undefined
|
||||
|| issueData.parentId !== undefined || issueData.executionWorkspaceId !== undefined) {
|
||||
@@ -11934,7 +11941,9 @@ export function issueService(db: Db) {
|
||||
);
|
||||
if (settings?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionWorkspaceSettings: {
|
||||
@@ -11944,6 +11953,7 @@ export function issueService(db: Db) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, row.id));
|
||||
});
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
@@ -11969,6 +11979,7 @@ export function issueService(db: Db) {
|
||||
if (owner) {
|
||||
await notifyExternalConversationSettlement(tx, { companyId: owner.companyId });
|
||||
await notifyApprovalWork(tx, owner.companyId);
|
||||
await notifyStatusCardIssueChange(tx, owner.companyId);
|
||||
}
|
||||
await notifyDecisionTargetIssueRemoval(tx, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
@@ -12101,6 +12112,7 @@ export function issueService(db: Db) {
|
||||
)
|
||||
: isNull(issues.executionRunId);
|
||||
const updated = await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
|
||||
await notifyDeferredExecutionEligibility(tx, { companyId: issueCompany.companyId });
|
||||
@@ -12155,7 +12167,9 @@ export function issueService(db: Db) {
|
||||
current.executionRunId === checkoutRunId) &&
|
||||
checkoutRunId
|
||||
) {
|
||||
const adopted = await db
|
||||
const adopted = await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
|
||||
return tx
|
||||
.update(issues)
|
||||
.set({
|
||||
checkoutRunId,
|
||||
@@ -12176,6 +12190,7 @@ export function issueService(db: Db) {
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
if (adopted) return adopted;
|
||||
}
|
||||
|
||||
@@ -12232,6 +12247,7 @@ export function issueService(db: Db) {
|
||||
}
|
||||
const staleExecutionRunId = current.executionRunId;
|
||||
const adopted = await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
|
||||
await notifyDeferredExecutionEligibility(tx, { companyId: issueCompany.companyId });
|
||||
@@ -12543,6 +12559,7 @@ export function issueService(db: Db) {
|
||||
patch.assigneeAgentId = null;
|
||||
}
|
||||
|
||||
await notifyStatusCardIssueChange(tx, existing.companyId);
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
|
||||
if (existing.checkoutRunId || existing.executionRunId || options.clearAssignee) {
|
||||
await notifyExternalConversationSettlement(tx, { companyId: existing.companyId, issueId: id });
|
||||
@@ -12602,12 +12619,12 @@ export function issueService(db: Db) {
|
||||
return created;
|
||||
},
|
||||
|
||||
deleteLabel: async (id: string) =>
|
||||
db
|
||||
.delete(labels)
|
||||
.where(eq(labels.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
deleteLabel: async (id: string) => db.transaction(async tx => {
|
||||
const [label] = await tx.select({ companyId: labels.companyId }).from(labels).where(eq(labels.id, id));
|
||||
if (!label) return null;
|
||||
await notifyStatusCardIssueChange(tx, label.companyId);
|
||||
return tx.delete(labels).where(eq(labels.id, id)).returning().then(rows => rows[0] ?? null);
|
||||
}),
|
||||
|
||||
listComments: async (
|
||||
issueId: string,
|
||||
@@ -12751,6 +12768,7 @@ export function issueService(db: Db) {
|
||||
.from(issueComments).where(eq(issueComments.id, commentId));
|
||||
if (!source) return null;
|
||||
await notifyExternalConversationSettlement(tx, source);
|
||||
await notifyStatusCardIssueChange(tx, source.companyId);
|
||||
const [comment] = await tx
|
||||
.delete(issueComments)
|
||||
.where(eq(issueComments.id, commentId))
|
||||
@@ -12790,6 +12808,7 @@ export function issueService(db: Db) {
|
||||
.for("update");
|
||||
if (!source) return null;
|
||||
await notifyExternalConversationSettlement(tx, source);
|
||||
await notifyStatusCardIssueChange(tx, source.companyId);
|
||||
await notifyChatInboundWakeNotices(tx, source);
|
||||
const now = new Date();
|
||||
const [comment] = await tx
|
||||
@@ -12858,7 +12877,7 @@ export function issueService(db: Db) {
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
): Promise<IssueComment> {
|
||||
if (dbOrTx === db && (actor.runId || actor.userId)) {
|
||||
if (dbOrTx === db) {
|
||||
const append = () =>
|
||||
measureRunTransaction(db, "response", async (tx) => {
|
||||
// Serialize run-authored comments on the issue so a provider retry
|
||||
@@ -13118,6 +13137,7 @@ export function issueService(db: Db) {
|
||||
currentUserRedactionOptions.enabled,
|
||||
);
|
||||
}
|
||||
await notifyStatusCardIssueChange(dbOrTx, issue.companyId);
|
||||
comment =
|
||||
shouldUpgradeExternalAuthorization ||
|
||||
shouldUpgradeAttachmentAuthorization
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
|
||||
import { pendingNativeRunProjectionCondition } from "./native-run-projection.js";
|
||||
import { nativeStatusReassessmentSourceIsSuperseded, reassessNativeStatusFromSource } from "./native-status-reassessment.js";
|
||||
@@ -334,6 +335,7 @@ export async function claimNativeSessionResumptions(input: {
|
||||
nativePhaseUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, row.run.id));
|
||||
await notifyStatusCardIssueChange(tx, run.companyId);
|
||||
await tx.update(issues).set({
|
||||
executionRunId: run.id,
|
||||
executionAgentNameKey: agent.name.trim().toLowerCase() || null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import {
|
||||
agents,
|
||||
heartbeatRuns,
|
||||
@@ -240,6 +241,7 @@ export async function claimNativeReviewExecutionLock(db: Db, input: {
|
||||
if (!run || !["queued", "running"].includes(run.status) || !admitted || !expected
|
||||
|| admitted.nativeReviewInteractionId !== expected.nativeReviewInteractionId
|
||||
|| admitted.nativeReviewDecisionId !== expected.nativeReviewDecisionId) return false;
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
const claimed = await tx.update(issues).set({
|
||||
executionRunId: input.runId,
|
||||
executionAgentNameKey: input.agentNameKey,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyNativeFinalizationWork } from "../native-finalization-notifications.js";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { notifyUnsafeWorkspaceExportRecovery, type UnsafeWorkspaceExportScope } from "../unsafe-workspace-export-notifications.js";
|
||||
@@ -107,6 +108,7 @@ async function recoverCandidate(db: Db, candidate: { runId: string; companyId: s
|
||||
nativePhase: "result_accepted", nativePhaseUpdatedAt: now,
|
||||
resultJson: { ...run.resultJson, finalizationPhase: "result_accepted", failureCode: null, originalFailureCode: null, nextAttemptAt: null }, updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, run.id)).returning();
|
||||
await notifyStatusCardIssueChange(tx, run.companyId);
|
||||
await tx.update(issues).set({ executionRunId: run.id, updatedAt: now }).where(eq(issues.id, issue.id));
|
||||
await appendHeartbeatRunEvent(tx as unknown as Db, { companyId: run.companyId, agentId: run.agentId, runId: run.id,
|
||||
eventType: "workspace_export_omitted", stream: "system", level: "info",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyNativeFinalizationWork } from "../native-finalization-notifications.js";
|
||||
import { notifyEnvironmentCleanup } from "../environment-cleanup-notifications.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -141,6 +142,7 @@ export async function retryNativeWorkspaceExport(input: {
|
||||
await tx.update(heartbeatRuns).set({ status: "running", finishedAt: null, error: null, errorCode: null,
|
||||
nativePhase: "result_accepted", nativePhaseUpdatedAt: now,
|
||||
resultJson: { ...current.run.resultJson, workspaceExportRetry: retry, finalizationPhase: "result_accepted", failureCode: null, originalFailureCode: null, nextAttemptAt: null }, updatedAt: now }).where(eq(heartbeatRuns.id, current.run.id));
|
||||
await notifyStatusCardIssueChange(tx, current.run.companyId);
|
||||
await tx.update(issues).set({ executionRunId: current.run.id, updatedAt: now }).where(eq(issues.id, current.issue.id));
|
||||
// Reclaim the same lease only for control-plane copyback. The normal
|
||||
// finalization settlement will stop/retain it again on success or failure.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
|
||||
import { and, eq, gt, inArray, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
@@ -187,6 +188,7 @@ export async function reconstructNativeWorkspace(input: {
|
||||
}).where(eq(issueRecoveryActions.id, source.action.id));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await tx.update(issues).set({ status: "todo", unblockDescriptor: null, blockedTransitionAt: null,
|
||||
blockedOwnerNotifiedAt: null, updatedAt: now }).where(eq(issues.id, issue.id));
|
||||
const publication = await persistActivity(db, { companyId: input.companyId, actorType: "user", actorId: input.actorId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
|
||||
import { requiredQualityReviewSchema } from "@paperclipai/shared";
|
||||
import { priorRecoveryApiReceipt } from "../recovery-effect-receipts.js";
|
||||
@@ -2081,6 +2082,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
}
|
||||
const patch = prepareIssueMonitorUpdate(target, input.monitor, actor);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyStatusCardIssueChange(tx, this.binding.companyId);
|
||||
const [updated] = await tx.update(issues).set({ ...patch, updatedAt: new Date() })
|
||||
.where(and(eq(issues.id, target.id), eq(issues.companyId, this.binding.companyId))).returning();
|
||||
if (!updated) throw notFound("Task not found");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type NativeStatusReassessmentScope = { companyId: string; issueId?: string; runId?: string; agentId?: string };
|
||||
|
||||
export async function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
await notifyIssueRecovery(tx, scope ? { companyId: scope.companyId } : undefined);
|
||||
return notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeStatusReassessment,
|
||||
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, runId: scope.runId, agentId: scope.agentId }));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
@@ -418,6 +419,7 @@ export async function preparePluginWorkspace(
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
await notifyStatusCardIssueChange(tx, task.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
|
||||
@@ -1013,6 +1014,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
|
||||
const row = await db.transaction(async (tx) => {
|
||||
if (updates.visibility !== undefined) {
|
||||
await notifyStatusCardIssueChange(tx, existingProject.companyId);
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: existingProject.companyId });
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existingProject.companyId}`}, 0))`);
|
||||
}
|
||||
@@ -1086,6 +1088,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
|
||||
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyStatusCardIssueChange(tx, project.companyId);
|
||||
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
|
||||
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { assertTaskWorkspaceExecutionAccess } from "./task-workspace-access.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -152,6 +153,7 @@ export async function reserveFreshPluginTaskWorkspace(input: {
|
||||
providerType: "adapter_managed", cwd: `/workspaces/${input.fresh.attemptId}`,
|
||||
metadata: { runnerTaskFreshWorkspace: input.fresh, runnerTaskWorkspaceOwner: input.owner },
|
||||
});
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await tx.update(issues).set({ executionWorkspaceId: input.fresh.attemptId, executionWorkspacePreference: "reuse_existing",
|
||||
workspaceBindingRevision: task.workspaceBindingRevision + 1,
|
||||
workspacePendingSelection: task.workspacePendingSelection?.expectedBindingRevision === task.workspaceBindingRevision
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
import { notifyDecisionTargetIssueRemoval } from "./decision-targets.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
@@ -1949,6 +1950,7 @@ export function routineService(
|
||||
if (createdIssue) {
|
||||
await notifyApprovalWork(txDb, createdIssue.companyId);
|
||||
await notifyDecisionTargetIssueRemoval(txDb, createdIssue.id);
|
||||
await notifyStatusCardIssueChange(txDb, createdIssue.companyId);
|
||||
await txDb.delete(issues).where(eq(issues.id, createdIssue.id));
|
||||
}
|
||||
const failureReason = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
@@ -133,6 +134,7 @@ export async function settleExternalConversation(db: Db, companyId: string, issu
|
||||
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(issues).set({ status: "in_review", updatedAt: new Date() })
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
|
||||
activity = (await persistActivity(tx as unknown as Db, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { chatConversations, issues, type Db } from "@paperclipai/db";
|
||||
@@ -49,6 +50,7 @@ export async function resumeExternalConversation(tx: Db, companyId: string, issu
|
||||
await notifyExternalConversationSettlement(tx, { companyId, issueId });
|
||||
// Only the verified idle convention may leave In Review automatically.
|
||||
// A real decision or delivery problem keeps the existing review semantics.
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await tx.update(issues).set({ status: "todo", updatedAt: new Date() })
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId),
|
||||
sql`${externalConversationStateSql()} = 'waiting'`));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { and, asc, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
import { companies, statusCards, type Db } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { registerDeadlineWork } from "./deadline-work.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
|
||||
import { statusCardService } from "./status-cards.js";
|
||||
|
||||
/** Feature-owned generation and assignment. Existing issue services retain
|
||||
* execution authority, durable wake dedupe and stalled-generation cleanup. */
|
||||
export function statusCardEvaluationService(db: Db, heartbeat: IssueAssignmentWakeupDeps) {
|
||||
const cards = statusCardService(db);
|
||||
const issues = issueService(db);
|
||||
const settings = instanceSettingsService(db);
|
||||
let reconstructReactive = true;
|
||||
let allChanges = false;
|
||||
const changes = new Set<string>();
|
||||
let enabled: boolean | null = null;
|
||||
let retryAt: number | null = null;
|
||||
|
||||
return {
|
||||
changed(hints?: ReadonlySet<string>) {
|
||||
for (const hint of hints ?? ["*"]) {
|
||||
if (hint === "*" || hint === "changes:*") { allChanges = true; if (hint === "*") enabled = null; }
|
||||
else if (hint.startsWith("changes:")) changes.add(hint.slice("changes:".length));
|
||||
}
|
||||
},
|
||||
async run(signal: AbortSignal) {
|
||||
if (enabled === null) enabled = (await settings.getExperimental()).enableStatusCards === true;
|
||||
if (!enabled || signal.aborted) return;
|
||||
const reconstruct = reconstructReactive;
|
||||
const evaluateAllChanges = allChanges;
|
||||
const companiesChanged = new Set(changes);
|
||||
changes.clear();
|
||||
reconstructReactive = false;
|
||||
allChanges = false;
|
||||
try {
|
||||
const result = await cards.tickDueStatusCards(new Date(), evaluateAllChanges ? "changed" : companiesChanged, reconstruct);
|
||||
retryAt = result.deferredCompanyIds.size ? Date.now() + 30_000 : null;
|
||||
for (const companyId of result.deferredCompanyIds) changes.add(companyId);
|
||||
const assignments = await Promise.allSettled(result.enqueued.map(async ({ cardId, generatingIssue }) => {
|
||||
// Once generated, dispatch or cancel even if shutdown begins: leaving
|
||||
// an unassigned claimed card is not a completed sweep.
|
||||
try {
|
||||
await queueIssueAssignmentWakeup({
|
||||
heartbeat, issue: generatingIssue,
|
||||
reason: "status_card_update_assigned",
|
||||
mutation: "status_card.scheduler_update_requested",
|
||||
contextSource: "status_card_scheduler",
|
||||
requestedByActorType: "system", taskKey: `status-card:${cardId}`,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
} catch (error) {
|
||||
await issues.update(generatingIssue.id, { status: "cancelled" });
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
const failures = assignments.filter(result => result.status === "rejected");
|
||||
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Status-card assignment failed");
|
||||
if (result.evaluated || result.enqueued.length) {
|
||||
logger.info({ evaluated: result.evaluated, enqueued: result.enqueued.length }, "status-card evaluation complete");
|
||||
}
|
||||
} catch (error) {
|
||||
reconstructReactive ||= reconstruct;
|
||||
allChanges ||= evaluateAllChanges;
|
||||
for (const companyId of companiesChanged) changes.add(companyId);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async next() {
|
||||
if (!enabled) return null;
|
||||
const [next] = await db.select({ at: statusCards.nextEvalAt }).from(statusCards)
|
||||
.innerJoin(companies, eq(companies.id, statusCards.companyId))
|
||||
.where(and(eq(companies.status, "active"), isNull(statusCards.archivedAt),
|
||||
isNull(statusCards.generatingIssueId), isNotNull(statusCards.nextEvalAt)))
|
||||
.orderBy(asc(statusCards.nextEvalAt)).limit(1);
|
||||
const deadline = next?.at?.getTime() ?? null;
|
||||
return retryAt === null ? deadline : Math.min(deadline ?? Infinity, retryAt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerStatusCardEvaluationWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
service: ReturnType<typeof statusCardEvaluationService>,
|
||||
canRun: () => boolean,
|
||||
) {
|
||||
return registerDeadlineWork(coordinator, DELIVERY_QUEUES.statusCardEvaluation, {
|
||||
run: service.run, next: service.next, onSettled: service.changed, canRun, retryMs: 30_000,
|
||||
// Batch source hints for at most one second after reconciliation. Do not
|
||||
// use minimumSpacingMs: known policy/retry deadlines must preempt batching.
|
||||
// This window alone arms no timer when the feature has no pending work.
|
||||
notificationCoalesceMs: 1_000,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { statusCards, statusCardUpdates } from "@paperclipai/db";
|
||||
@@ -36,6 +37,7 @@ export async function finalizeStatusCardsForStalledGeneration(
|
||||
) {
|
||||
if (!STALLED_GENERATION_STATUSES.has(issue.status)) return [];
|
||||
|
||||
await notifyStatusCardEvaluation(dbOrTx, issue.companyId);
|
||||
const now = new Date();
|
||||
const failureReason = failureReasonForIssue(issue);
|
||||
const cards = await dbOrTx
|
||||
@@ -45,6 +47,7 @@ export async function finalizeStatusCardsForStalledGeneration(
|
||||
failureReason,
|
||||
generatingIssueId: null,
|
||||
nextEvalAt: null,
|
||||
nextEvalReason: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
/** Card/settings changes reconstruct deadlines; issue changes also evaluate reactive scope. */
|
||||
export function notifyStatusCardEvaluation(transaction: object, companyId?: string) {
|
||||
return notifyDeliveryWork(transaction, DELIVERY_QUEUES.statusCardEvaluation, companyId ? `cards:${companyId}` : "*");
|
||||
}
|
||||
|
||||
export function notifyStatusCardIssueChange(transaction: object, companyId?: string) {
|
||||
return notifyDeliveryWork(transaction, DELIVERY_QUEUES.statusCardEvaluation, companyId ? `changes:${companyId}` : "changes:*");
|
||||
}
|
||||
@@ -109,29 +109,42 @@ export function statusCardFingerprintHash(fingerprint: StatusCardFingerprint) {
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
|
||||
// These caches contain only timezone/policy calculations, never card or company
|
||||
// data. Bound both maps because timezone aliases and hour pairs are user input.
|
||||
const activeHoursFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const upcomingActiveHours = new Map<string, { closedAt: number; opensAt: number }>();
|
||||
|
||||
function remember<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size > limit) cache.delete(cache.keys().next().value!);
|
||||
return value;
|
||||
}
|
||||
|
||||
function activeHoursFormatter(timezone: string) {
|
||||
const cached = activeHoursFormatters.get(timezone);
|
||||
return remember(activeHoursFormatters, timezone, cached ?? new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
||||
}), 32);
|
||||
}
|
||||
|
||||
export function isWithinStatusCardActiveHours(policy: StatusCardRefreshPolicy, now: Date) {
|
||||
if (!policy.activeHours) return true;
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: policy.activeHours.timezone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
const [startHour, startMinute] = policy.activeHours.start.split(":").map(Number);
|
||||
const [endHour, endMinute] = policy.activeHours.end.split(":").map(Number);
|
||||
const start = startHour! * 60 + startMinute!;
|
||||
const end = endHour! * 60 + endMinute!;
|
||||
if (start === end) return false;
|
||||
const parts = activeHoursFormatter(policy.activeHours.timezone).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
return start <= end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
|
||||
export function nextStatusCardEvaluationAt(policy: StatusCardRefreshPolicy, now: Date) {
|
||||
if (policy.mode === "manual") return null;
|
||||
const seconds = policy.mode === "interval"
|
||||
? (policy.intervalMinutes ?? 15) * 60
|
||||
: Math.min(policy.debounceSeconds ?? 60, 60);
|
||||
if (policy.mode !== "interval") return null;
|
||||
const seconds = (policy.intervalMinutes ?? 15) * 60;
|
||||
return new Date(now.getTime() + seconds * 1000);
|
||||
}
|
||||
|
||||
@@ -172,3 +185,51 @@ export function evaluateStatusCardPolicy(input: {
|
||||
}
|
||||
return { action: "run" as const };
|
||||
}
|
||||
|
||||
/** A retry exists only while filtered changes remain. Use the owning policy's
|
||||
* actual eligibility boundary instead of repeatedly rediscovering a closed gate. */
|
||||
export function nextStatusCardPolicyEligibility(input: {
|
||||
policy: StatusCardRefreshPolicy;
|
||||
now: Date;
|
||||
lastChangeAt: Date;
|
||||
completedUpdateTimes: Date[];
|
||||
tokensToday: number;
|
||||
}): Date | null {
|
||||
if (input.policy.mode === "manual") return null;
|
||||
const hours = input.policy.activeHours;
|
||||
if (hours && hours.start === hours.end) return null;
|
||||
let at = input.now.getTime();
|
||||
if (input.tokensToday >= (input.policy.dailyTokenCap ?? 100_000)) {
|
||||
const day = new Date(input.now);
|
||||
day.setUTCHours(24, 0, 0, 0);
|
||||
at = Math.max(at, day.getTime());
|
||||
}
|
||||
if (input.policy.mode === "reactive") {
|
||||
at = Math.max(at, input.lastChangeAt.getTime() + (input.policy.debounceSeconds ?? 60) * 1000);
|
||||
const cap = input.policy.maxUpdatesPerHour ?? 6;
|
||||
const recent = input.completedUpdateTimes.map(time => time.getTime())
|
||||
.filter(time => time > at - 3_600_000).sort((a, b) => a - b);
|
||||
if (recent.length >= cap) at = Math.max(at, recent[recent.length - cap]! + 3_600_000 + 1);
|
||||
}
|
||||
// Check the precise instant before reusing a minute-aligned opening. Some
|
||||
// historical timezone offsets include seconds, so a window may be open now.
|
||||
if (!hours || isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
const key = JSON.stringify([hours.timezone, hours.start, hours.end]);
|
||||
const cached = upcomingActiveHours.get(key);
|
||||
if (cached && at >= cached.closedAt && at < cached.opensAt) {
|
||||
remember(upcomingActiveHours, key, cached, 256);
|
||||
return new Date(cached.opensAt);
|
||||
}
|
||||
const closedAt = at;
|
||||
// Active-hour precision is one local minute. Walking UTC minutes also honors
|
||||
// skipped/repeated local times at daylight-saving transitions.
|
||||
const end = at + 48 * 3_600_000;
|
||||
for (at = Math.floor(at / 60_000) * 60_000 + 60_000; at <= end; at += 60_000) {
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) {
|
||||
remember(upcomingActiveHours, key, { closedAt, opensAt: at }, 256);
|
||||
return new Date(at);
|
||||
}
|
||||
}
|
||||
// No opening inside the existing bounded search horizon. Retain changes.
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
costEvents,
|
||||
documentRevisions,
|
||||
documents,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
issueComments,
|
||||
statusCards,
|
||||
statusCardUpdates,
|
||||
withDedicatedDbConnection,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
@@ -24,7 +27,8 @@ import { logger } from "../middleware/logger.js";
|
||||
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
|
||||
import { builtInAgentService } from "./built-in-agents.js";
|
||||
import { companySearchService } from "./company-search.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { executeIssuePostCommitActions, issueService, type IssuePostCommitAction } from "./issues.js";
|
||||
import { publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { SUMMARIZER_BUILT_IN_KEY } from "./summary-slots.js";
|
||||
import {
|
||||
buildStatusCardFingerprint,
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
extractIssueMentions,
|
||||
filterStatusCardChanges,
|
||||
nextStatusCardEvaluationAt,
|
||||
nextStatusCardPolicyEligibility,
|
||||
STATUS_CARD_MAX_MENTIONED_ISSUES,
|
||||
statusCardChangesHash,
|
||||
statusCardFingerprintHash,
|
||||
@@ -151,9 +156,15 @@ export function statusCardService(
|
||||
db: Db,
|
||||
deps: { issuesSvc?: ReturnType<typeof issueService> } = {},
|
||||
) {
|
||||
const builtIns = builtInAgentService(db);
|
||||
const issuesSvc = deps.issuesSvc ?? issueService(db);
|
||||
const issuesSvc = () => deps.issuesSvc ?? issueService(db);
|
||||
const searchSvc = companySearchService(db);
|
||||
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
function mutate<T>(companyId: string, write: (tx: Transaction) => Promise<T>) {
|
||||
return db.transaction(async tx => {
|
||||
await notifyStatusCardEvaluation(tx, companyId);
|
||||
return write(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function readWatchedIssueVisibility(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) {
|
||||
@@ -240,10 +251,10 @@ export function statusCardService(
|
||||
};
|
||||
const agentId = actor.agentId;
|
||||
if (!agentId) {
|
||||
return db.insert(statusCards).values(values).returning().then((rows) => rows[0]!);
|
||||
return mutate(companyId, tx => tx.insert(statusCards).values(values).returning().then((rows) => rows[0]!));
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
return mutate(companyId, async (tx) => {
|
||||
const author = await tx
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
@@ -292,11 +303,12 @@ export function statusCardService(
|
||||
...(input.refreshPolicy !== undefined
|
||||
? {
|
||||
refreshPolicy: input.refreshPolicy,
|
||||
nextEvalAt: card.archivedAt ? null : nextStatusCardEvaluationAt(input.refreshPolicy, now),
|
||||
nextEvalReason: "refresh",
|
||||
nextEvalAt: card.archivedAt ? null : input.refreshPolicy.mode === "reactive" && card.queries.length > 0 ? now : nextStatusCardEvaluationAt(input.refreshPolicy, now),
|
||||
}
|
||||
: {}),
|
||||
...(archiveChanged && input.archived
|
||||
? { archivedAt: now, archivedByAgentId: actor.agentId, archivedByUserId: actor.userId, nextEvalAt: null }
|
||||
? { archivedAt: now, archivedByAgentId: actor.agentId, archivedByUserId: actor.userId, nextEvalAt: null, nextEvalReason: null }
|
||||
: {}),
|
||||
...(archiveChanged && !input.archived
|
||||
? {
|
||||
@@ -306,24 +318,27 @@ export function statusCardService(
|
||||
lastChangeAt: now,
|
||||
lastUpdateRunKind: null,
|
||||
nextEvalAt: card.queries.length > 0 ? now : null,
|
||||
nextEvalReason: "refresh",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const next = await db.update(statusCards).set({
|
||||
const next = await mutate(card.companyId, tx => tx.update(statusCards).set({
|
||||
...values,
|
||||
...(archiveChanged && input.archived ? { generatingIssueId: null, pendingChangeHash: null } : {}),
|
||||
}).where(eq(statusCards.id, card.id)).returning().then((rows) => rows[0]!);
|
||||
}).where(eq(statusCards.id, card.id)).returning().then((rows) => rows[0]!));
|
||||
if (archiveChanged && input.archived && card.generatingIssueId) {
|
||||
const generationIssue = await db.select().from(issues).where(eq(issues.id, card.generatingIssueId)).then((rows) => rows[0] ?? null);
|
||||
if (generationIssue && !TERMINAL_ISSUE_STATUSES.has(generationIssue.status)) {
|
||||
await issuesSvc.update(generationIssue.id, { status: "cancelled" });
|
||||
await issuesSvc().update(generationIssue.id, { status: "cancelled" });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
return db.delete(statusCards).where(eq(statusCards.id, id)).returning().then((rows) => rows[0] ?? null);
|
||||
const card = await getById(id);
|
||||
if (!card) return null;
|
||||
return mutate(card.companyId, tx => tx.delete(statusCards).where(eq(statusCards.id, id)).returning().then((rows) => rows[0] ?? null));
|
||||
}
|
||||
|
||||
async function listUpdates(cardId: string) {
|
||||
@@ -360,7 +375,7 @@ export function statusCardService(
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (override) return override.id;
|
||||
}
|
||||
const builtIn = await builtIns.get(card.companyId, SUMMARIZER_BUILT_IN_KEY);
|
||||
const builtIn = await builtInAgentService(db).get(card.companyId, SUMMARIZER_BUILT_IN_KEY);
|
||||
if (builtIn.status !== "ready" || !builtIn.agentId) {
|
||||
throw unprocessable("Summarizer built-in agent is not configured", {
|
||||
code: "summarizer_not_configured",
|
||||
@@ -391,7 +406,7 @@ export function statusCardService(
|
||||
|
||||
let deduplicated = false;
|
||||
const createdAt = new Date();
|
||||
const created = await issuesSvc.create(card.companyId, {
|
||||
const created = await issuesSvc().create(card.companyId, {
|
||||
title: `Compile status card: ${card.title ?? card.interestPrompt.slice(0, 80)}`,
|
||||
description: compileDescription(card, null, hash),
|
||||
status: "todo",
|
||||
@@ -409,16 +424,16 @@ export function statusCardService(
|
||||
// covers idempotency-key hits that resolve to a terminal task (done/cancelled)
|
||||
// as well as a `blocked` one that a manual re-kick is reviving.
|
||||
const reopened = deduplicated && (TERMINAL_ISSUE_STATUSES.has(created.status) || created.status === "blocked")
|
||||
? await issuesSvc.update(created.id, { status: "todo", assigneeAgentId: summarizerAgentId })
|
||||
? await issuesSvc().update(created.id, { status: "todo", assigneeAgentId: summarizerAgentId })
|
||||
: created;
|
||||
const generationIssue = await issuesSvc.update(reopened!.id, {
|
||||
const generationIssue = await issuesSvc().update(reopened!.id, {
|
||||
description: compileDescription(card, reopened!.id, hash),
|
||||
});
|
||||
const [nextCard] = await db
|
||||
const [nextCard] = await mutate(card.companyId, tx => tx
|
||||
.update(statusCards)
|
||||
.set({ generatingIssueId: generationIssue!.id, state: "compiling", failureReason: null, updatedAt: createdAt })
|
||||
.where(eq(statusCards.id, card.id))
|
||||
.returning();
|
||||
.returning());
|
||||
return {
|
||||
card: nextCard!,
|
||||
generatingIssue: generationIssue!,
|
||||
@@ -464,7 +479,7 @@ export function statusCardService(
|
||||
if (card.archivedAt) throw unprocessable("Archived status cards cannot accept generation writes");
|
||||
await assertSummarizerWriter(card, input.generationIssueId, actor);
|
||||
const now = new Date();
|
||||
return db.transaction(async (tx) => {
|
||||
return mutate(card.companyId, async (tx) => {
|
||||
const current = await tx.select().from(statusCards).where(eq(statusCards.id, card.id)).then((rows) => rows[0] ?? null);
|
||||
if (!current || current.archivedAt || current.generatingIssueId !== input.generationIssueId) {
|
||||
throw conflict("Status-card compilation was superseded by a newer task");
|
||||
@@ -528,9 +543,10 @@ export function statusCardService(
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
options: StatusCardReadOptions = {},
|
||||
queryDb: Db = db,
|
||||
): Promise<CompanySearchIssueSummary[]> {
|
||||
if (issueIds.length === 0) return [];
|
||||
const rows = await db
|
||||
const rows = await queryDb
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
@@ -580,12 +596,12 @@ export function statusCardService(
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? [], options);
|
||||
}
|
||||
|
||||
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}, onNextMembershipChange?: (at: Date | null) => void, queryDb: Db = db) {
|
||||
const issueMap = new Map<string, CompanySearchIssueSummary>();
|
||||
for (const storedQuery of card.queries) {
|
||||
const query = companySearchQuerySchema.parse(storedQuery);
|
||||
const response = await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
const response = await companySearchService(queryDb).search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition, onNextMembershipChange, issueResultsOnly: true,
|
||||
});
|
||||
for (const result of response.results) {
|
||||
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
|
||||
@@ -596,12 +612,12 @@ export function statusCardService(
|
||||
const mentioned = await loadIssueSummaries(
|
||||
card.companyId,
|
||||
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
|
||||
options,
|
||||
options, queryDb,
|
||||
);
|
||||
for (const issue of mentioned) issueMap.set(issue.id, issue);
|
||||
const snapshot = [...issueMap.values()];
|
||||
if (snapshot.length === 0) return snapshot;
|
||||
const latestHumanComments = await db
|
||||
const latestHumanComments = await queryDb
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
// The postgres-js driver returns the `max()` aggregate over a timestamp
|
||||
@@ -622,14 +638,50 @@ export function statusCardService(
|
||||
return snapshot.map((issue) => ({ ...issue, latestHumanCommentAt: commentByIssueId.get(issue.id) ?? null }));
|
||||
}
|
||||
|
||||
async function requestRefresh(cardId: string, input: {
|
||||
function nextEvaluationAt(card: StatusCardRow, membershipAt: Date | null, now: Date) {
|
||||
const interval = nextStatusCardEvaluationAt(card.refreshPolicy, now);
|
||||
return card.refreshPolicy.mode === "reactive" && (card.refreshPolicy.triggers.membershipChanges || card.refreshPolicy.triggers.anyUpdate)
|
||||
? membershipAt : interval;
|
||||
}
|
||||
|
||||
async function cancelUnclaimedRefreshTask(card: StatusCardRow, issueId: string) {
|
||||
const postCommitActions: IssuePostCommitAction[] = [];
|
||||
const publications: ActivityPublication[] = [];
|
||||
const admitted = await db.transaction(async tx => {
|
||||
// Match the canonical mutation's issue-first lock order. A terminal
|
||||
// transition cannot race this recheck and have its receipt rewritten.
|
||||
const [current] = await tx.select({ status: issues.status }).from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, card.companyId))).for("update");
|
||||
if (!current) return false;
|
||||
const [generation] = await tx.select({ id: statusCardUpdates.id }).from(statusCardUpdates)
|
||||
.where(and(eq(statusCardUpdates.cardId, card.id), eq(statusCardUpdates.generationIssueId, issueId))).limit(1);
|
||||
// A failed revision CAS excludes any later claim of this revision's task.
|
||||
// A summary clears the claim and records its ledger atomically, so even
|
||||
// an active task whose winner already finished remains admitted work.
|
||||
if (generation) return true;
|
||||
if (TERMINAL_ISSUE_STATUSES.has(current.status)) return false;
|
||||
// Bind preflight readers to this transaction too: a root-pool reader
|
||||
// would wait forever when this transaction owns its only connection.
|
||||
await issueService(tx as unknown as Db).update(issueId,
|
||||
{ status: "cancelled", companyGuard: card.companyId }, tx, publications, postCommitActions);
|
||||
return false;
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
await executeIssuePostCommitActions(db, postCommitActions);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
async function refreshCurrentCard(cardId: string, input: {
|
||||
full?: boolean;
|
||||
trigger?: "manual" | "interval" | "reactive" | "restore";
|
||||
actor?: StatusCardActor;
|
||||
now?: Date;
|
||||
} = {}) {
|
||||
const card = await getById(cardId);
|
||||
if (!card) throw notFound("Status card not found");
|
||||
} = {}, superseded = false) {
|
||||
const [source] = await db.select({ card: statusCards, revision: sql<string>`${statusCards}.xmin::text` })
|
||||
.from(statusCards).where(eq(statusCards.id, cardId));
|
||||
if (!source) throw notFound("Status card not found");
|
||||
const { card, revision } = source;
|
||||
const currentRevision = sql`${statusCards}.xmin::text = ${revision}`;
|
||||
if (card.archivedAt) throw unprocessable("Archived status cards cannot be refreshed");
|
||||
if (card.queries.length === 0) throw conflict("Compile the status-card query before refreshing it");
|
||||
if (card.generatingIssueId) {
|
||||
@@ -642,22 +694,29 @@ export function statusCardService(
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
const snapshot = await executeQueries(card);
|
||||
if (superseded && input.trigger !== undefined && input.trigger !== "manual" && input.trigger !== "restore"
|
||||
&& (card.refreshPolicy.mode === "manual" || (card.refreshPolicy.mode === "interval" && card.nextEvalAt && card.nextEvalAt > now))) {
|
||||
return { card, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
}
|
||||
let membershipAt: Date | null = null;
|
||||
const snapshot = await executeQueries(card, {}, at => { if (at && (!membershipAt || at < membershipAt)) membershipAt = at; });
|
||||
const fingerprint = buildStatusCardFingerprint(snapshot);
|
||||
const allChanges = diffStatusCardFingerprint(card.fingerprint as StatusCardFingerprint | null, fingerprint);
|
||||
const changes = filterStatusCardChanges(allChanges, card.refreshPolicy);
|
||||
const trigger = input.trigger ?? "manual";
|
||||
const forceRun = trigger === "manual" || trigger === "restore";
|
||||
const nextEvalAt = nextStatusCardEvaluationAt(card.refreshPolicy, now);
|
||||
const nextEvalAt = nextEvaluationAt(card, membershipAt, now);
|
||||
if (!forceRun && changes.length === 0) {
|
||||
const [next] = await db.update(statusCards).set({
|
||||
const [next] = await mutate(card.companyId, tx => tx.update(statusCards).set({
|
||||
pendingChangeCount: 0,
|
||||
pendingChangeHash: null,
|
||||
lastChangeAt: null,
|
||||
state: "active",
|
||||
nextEvalAt,
|
||||
}).where(eq(statusCards.id, card.id)).returning();
|
||||
return { card: next!, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
nextEvalReason: nextEvalAt ? card.refreshPolicy.mode === "reactive" ? "membership" : "refresh" : null,
|
||||
}).where(and(eq(statusCards.id, card.id), currentRevision)).returning());
|
||||
if (!next) return undefined;
|
||||
return { card: next, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
}
|
||||
|
||||
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
@@ -678,14 +737,20 @@ export function statusCardService(
|
||||
manual: forceRun,
|
||||
});
|
||||
if (decision.action !== "run") {
|
||||
const [next] = await db.update(statusCards).set({
|
||||
const [next] = await mutate(card.companyId, tx => tx.update(statusCards).set({
|
||||
pendingChangeCount: changes.length,
|
||||
pendingChangeHash,
|
||||
lastChangeAt,
|
||||
state: decision.action === "pause_budget" ? "paused_budget" : decision.action === "pause_hours" ? "paused_hours" : "active",
|
||||
nextEvalAt: decision.action === "wait" && "dueAt" in decision ? decision.dueAt : nextEvalAt,
|
||||
}).where(eq(statusCards.id, card.id)).returning();
|
||||
return { card: next!, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
nextEvalReason: "policy",
|
||||
nextEvalAt: nextStatusCardPolicyEligibility({
|
||||
policy: card.refreshPolicy, now, lastChangeAt,
|
||||
completedUpdateTimes: recent.filter(row => row.kind !== "compile" && row.finishedAt).map(row => row.startedAt),
|
||||
tokensToday: Number(daily[0]?.tokens ?? 0),
|
||||
}),
|
||||
}).where(and(eq(statusCards.id, card.id), currentRevision)).returning());
|
||||
if (!next) return undefined;
|
||||
return { card: next, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
}
|
||||
|
||||
const history = await listUpdates(card.id);
|
||||
@@ -707,7 +772,7 @@ export function statusCardService(
|
||||
: null;
|
||||
const fingerprintHash = statusCardFingerprintHash(fingerprint);
|
||||
let deduplicated = false;
|
||||
const created = await issuesSvc.create(card.companyId, {
|
||||
const created = await issuesSvc().create(card.companyId, {
|
||||
title: `${kind === "full" ? "Rebuild" : "Update"} status card: ${card.title ?? card.interestPrompt.slice(0, 80)}`,
|
||||
description: updateDescription({ card, generationIssueId: null, fingerprint, changes, kind, trigger, previousSummary, snapshot }),
|
||||
status: "todo",
|
||||
@@ -716,37 +781,44 @@ export function statusCardService(
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
hiddenAt: now,
|
||||
idempotencyKey: `status-card-update:${card.id}:${fingerprintHash}`,
|
||||
// Claim ownership is revision-scoped too. A superseded refresh may cancel
|
||||
// its unclaimed task; it must not share that task with a newer revision
|
||||
// which has the same watched fingerprint but has not claimed the card yet.
|
||||
// Concurrent refreshes of this revision still deduplicate normally.
|
||||
idempotencyKey: `status-card-update:${card.id}:${revision}:${fingerprintHash}`,
|
||||
onDeduplicated: (reason) => { deduplicated = reason === "idempotency_key"; },
|
||||
});
|
||||
const reopened = deduplicated && TERMINAL_ISSUE_STATUSES.has(created.status)
|
||||
? await issuesSvc.update(created.id, { status: "todo", assigneeAgentId: summarizerAgentId })
|
||||
? await issuesSvc().update(created.id, { status: "todo", assigneeAgentId: summarizerAgentId })
|
||||
: created;
|
||||
const generationIssue = await issuesSvc.update(reopened!.id, {
|
||||
const generationIssue = await issuesSvc().update(reopened!.id, {
|
||||
description: updateDescription({ card, generationIssueId: reopened!.id, fingerprint, changes, kind, trigger, previousSummary, snapshot }),
|
||||
});
|
||||
const priorGenerationPredicate = card.generatingIssueId
|
||||
? eq(statusCards.generatingIssueId, card.generatingIssueId)
|
||||
: isNull(statusCards.generatingIssueId);
|
||||
const [next] = await db.update(statusCards).set({
|
||||
const [next] = await mutate(card.companyId, tx => tx.update(statusCards).set({
|
||||
generatingIssueId: generationIssue!.id,
|
||||
pendingChangeCount: changes.length,
|
||||
pendingChangeHash,
|
||||
lastChangeAt,
|
||||
state: "active",
|
||||
nextEvalAt,
|
||||
nextEvalReason: nextEvalAt ? card.refreshPolicy.mode === "reactive" ? "membership" : "refresh" : null,
|
||||
failureReason: null,
|
||||
}).where(and(eq(statusCards.id, card.id), isNull(statusCards.archivedAt), or(isNull(statusCards.generatingIssueId), priorGenerationPredicate))).returning();
|
||||
}).where(and(eq(statusCards.id, card.id), isNull(statusCards.archivedAt), currentRevision, or(isNull(statusCards.generatingIssueId), priorGenerationPredicate))).returning());
|
||||
if (!next) {
|
||||
const winner = await getById(card.id);
|
||||
if (!winner?.generatingIssueId) {
|
||||
if (!TERMINAL_ISSUE_STATUSES.has(generationIssue!.status)) {
|
||||
await issuesSvc.update(generationIssue!.id, { status: "cancelled" });
|
||||
const admitted = await cancelUnclaimedRefreshTask(card, generationIssue!.id);
|
||||
if (winner && !winner.archivedAt) {
|
||||
if (admitted) return { card: winner, generatingIssue: null, alreadyGenerating: false, enqueued: false, kind, changes };
|
||||
return undefined;
|
||||
}
|
||||
throw conflict("Status-card refresh claim was lost");
|
||||
}
|
||||
if (generationIssue!.id !== winner.generatingIssueId && !TERMINAL_ISSUE_STATUSES.has(generationIssue!.status)) {
|
||||
await issuesSvc.update(generationIssue!.id, { status: "cancelled" });
|
||||
if (generationIssue!.id !== winner.generatingIssueId) {
|
||||
await cancelUnclaimedRefreshTask(card, generationIssue!.id);
|
||||
}
|
||||
const winnerIssue = await db.select().from(issues).where(eq(issues.id, winner.generatingIssueId)).then((rows) => rows[0] ?? null);
|
||||
return { card: winner, generatingIssue: winnerIssue, alreadyGenerating: true, enqueued: false, kind, changes };
|
||||
@@ -765,28 +837,67 @@ export function statusCardService(
|
||||
return { card: next, generatingIssue: generationIssue!, alreadyGenerating: deduplicated, enqueued: true, kind, changes };
|
||||
}
|
||||
|
||||
async function tickDueStatusCards(now = new Date()) {
|
||||
const due = await db.select().from(statusCards).where(and(isNull(statusCards.archivedAt), isNull(statusCards.generatingIssueId), isNotNull(statusCards.nextEvalAt), lte(statusCards.nextEvalAt, now)));
|
||||
async function requestRefresh(cardId: string, input: Parameters<typeof refreshCurrentCard>[1] = {}) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const result = await refreshCurrentCard(cardId, input, attempt > 0);
|
||||
if (result) return result;
|
||||
}
|
||||
throw conflict("Status-card refresh was superseded by concurrent edits");
|
||||
}
|
||||
|
||||
async function tickDueStatusCards(now = new Date(), reactiveCompanies?: ReadonlySet<string> | "changed", reconstruct = false) {
|
||||
const changedScope = reactiveCompanies === "changed" ? sql`true` : reactiveCompanies?.size
|
||||
? inArray(statusCards.companyId, [...reactiveCompanies]) : sql`false`;
|
||||
// Startup recovers lost source hints for membership deadlines as well as
|
||||
// disarmed cards. Saved policy gates and retry leases keep their owner.
|
||||
// Legacy untagged leases are at most five minutes from their claim time:
|
||||
// preserve that bounded ambiguity, never a days-away membership deadline.
|
||||
const startupScope = reconstruct ? or(
|
||||
isNull(statusCards.nextEvalAt), eq(statusCards.nextEvalReason, "membership"),
|
||||
and(isNull(statusCards.nextEvalReason), isNull(statusCards.pendingChangeHash),
|
||||
eq(statusCards.state, "active"), gt(statusCards.nextEvalAt, new Date(now.getTime() + 5 * 60_000))),
|
||||
) : undefined;
|
||||
const reactiveScope = or(startupScope, changedScope);
|
||||
const due = await db.select({ card: statusCards }).from(statusCards)
|
||||
.innerJoin(companies, eq(companies.id, statusCards.companyId))
|
||||
.where(and(eq(companies.status, "active"), isNull(statusCards.archivedAt), isNull(statusCards.generatingIssueId),
|
||||
or(and(isNotNull(statusCards.nextEvalAt), lte(statusCards.nextEvalAt, now)),
|
||||
and(reactiveScope,
|
||||
or(isNull(statusCards.nextEvalReason), ne(statusCards.nextEvalReason, "retry"), isNull(statusCards.nextEvalAt), lte(statusCards.nextEvalAt, now)),
|
||||
ne(statusCards.state, "error"), ne(statusCards.state, "compiling"), sql`${statusCards.refreshPolicy}->>'mode' = 'reactive'`, sql`jsonb_array_length(${statusCards.queries}) > 0`))))
|
||||
.then(rows => rows.map(row => row.card));
|
||||
const enqueued: Array<{ cardId: string; generatingIssue: typeof issues.$inferSelect }> = [];
|
||||
let evaluated = 0;
|
||||
const deferredCompanyIds = new Set<string>();
|
||||
for (const candidate of due) {
|
||||
const claimUntil = new Date(now.getTime() + 5 * 60 * 1000);
|
||||
const [claimed] = await db.update(statusCards).set({ nextEvalAt: claimUntil })
|
||||
.where(and(eq(statusCards.id, candidate.id), isNull(statusCards.generatingIssueId), lte(statusCards.nextEvalAt, now)))
|
||||
.returning();
|
||||
if (!claimed) continue;
|
||||
evaluated += 1;
|
||||
try {
|
||||
const result = await requestRefresh(claimed.id, { trigger: claimed.refreshPolicy.mode === "reactive" ? "reactive" : "interval", now });
|
||||
if (result.enqueued && result.generatingIssue) enqueued.push({ cardId: claimed.id, generatingIssue: result.generatingIssue });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, cardId: claimed.id, companyId: claimed.companyId },
|
||||
"status card scheduled refresh failed",
|
||||
// nextEvalAt is both a retry lease and an evaluation deadline. A source
|
||||
// event may preempt a genuine future deadline, so serialize scheduler
|
||||
// evaluation across connections without holding a row lock while the
|
||||
// existing issue/card services commit through their own transactions.
|
||||
await withDedicatedDbConnection(db, connection => connection.transaction(async lockTx => {
|
||||
const [lock] = await lockTx.execute<{ acquired: boolean }>(
|
||||
sql`select pg_try_advisory_xact_lock(hashtextextended(${candidate.id}, 0)) as acquired`,
|
||||
);
|
||||
}
|
||||
if (!lock?.acquired) { deferredCompanyIds.add(candidate.companyId); return; }
|
||||
const claimUntil = new Date(now.getTime() + 5 * 60 * 1000);
|
||||
const [claimed] = await mutate(candidate.companyId, tx => tx.update(statusCards).set({ nextEvalAt: claimUntil, nextEvalReason: "retry" })
|
||||
.where(and(eq(statusCards.id, candidate.id), isNull(statusCards.archivedAt), isNull(statusCards.generatingIssueId),
|
||||
candidate.nextEvalAt ? eq(statusCards.nextEvalAt, candidate.nextEvalAt) : isNull(statusCards.nextEvalAt)))
|
||||
.returning());
|
||||
if (!claimed) return;
|
||||
evaluated += 1;
|
||||
try {
|
||||
const result = await requestRefresh(claimed.id, { trigger: claimed.refreshPolicy.mode === "reactive" ? "reactive" : "interval", now });
|
||||
if (result.enqueued && result.generatingIssue) enqueued.push({ cardId: claimed.id, generatingIssue: result.generatingIssue });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, cardId: claimed.id, companyId: claimed.companyId },
|
||||
"status card scheduled refresh failed",
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
return { evaluated, enqueued };
|
||||
return { evaluated, enqueued, deferredCompanyIds };
|
||||
}
|
||||
|
||||
async function writeSummary(cardId: string, input: WriteStatusCardSummary, actor: StatusCardWriter) {
|
||||
@@ -796,7 +907,7 @@ export function statusCardService(
|
||||
await assertSummarizerWriter(card, input.generationIssueId, actor);
|
||||
if (card.queries.length === 0) throw conflict("Compile the status-card query before writing its summary");
|
||||
const now = new Date();
|
||||
return db.transaction(async (tx) => {
|
||||
return mutate(card.companyId, async (tx) => {
|
||||
const current = await tx.select().from(statusCards).where(eq(statusCards.id, card.id)).then((rows) => rows[0] ?? null);
|
||||
if (!current || current.archivedAt || current.generatingIssueId !== input.generationIssueId) {
|
||||
throw conflict("Status-card generation was superseded by a newer task");
|
||||
@@ -816,9 +927,11 @@ export function statusCardService(
|
||||
const mentionedIssueIds = await resolveMentionedIssueIds(current.companyId, input.markdown);
|
||||
// Current watched membership: compiled-query matches plus the issues this
|
||||
// summary mentions.
|
||||
const watchedNow = buildStatusCardFingerprint(
|
||||
await executeQueries({ ...current, mentionedIssueIds }),
|
||||
);
|
||||
let membershipAt: Date | null = null;
|
||||
// All watched-source reads use this transaction. Borrowing the root
|
||||
// pool here would deadlock a one-connection installation.
|
||||
const watchedSnapshot = await executeQueries({ ...current, mentionedIssueIds }, {}, at => { if (at && (!membershipAt || at < membershipAt)) membershipAt = at; }, tx as unknown as Db);
|
||||
const watchedNow = buildStatusCardFingerprint(watchedSnapshot);
|
||||
let snapshot: StatusCardFingerprint;
|
||||
if (payloadFingerprint) {
|
||||
// Keep the generation-time fingerprint as the change baseline: issues
|
||||
@@ -889,7 +1002,8 @@ export function statusCardService(
|
||||
pendingChangeCount: 0,
|
||||
pendingChangeHash: null,
|
||||
lastChangeAt: null,
|
||||
nextEvalAt: nextStatusCardEvaluationAt(current.refreshPolicy, now),
|
||||
nextEvalReason: current.refreshPolicy.mode === "reactive" || current.refreshPolicy.mode === "interval" ? "refresh" : membershipAt ? "membership" : null,
|
||||
nextEvalAt: current.refreshPolicy.mode === "reactive" ? now : nextEvaluationAt(current, membershipAt, now),
|
||||
updatedAt: now,
|
||||
}).where(and(eq(statusCards.id, current.id), eq(statusCards.generatingIssueId, input.generationIssueId))).returning();
|
||||
if (!next) throw conflict("Status-card generation was superseded by a newer task");
|
||||
@@ -935,7 +1049,7 @@ export function statusCardService(
|
||||
return {
|
||||
query,
|
||||
result: await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
issueReadCondition: options.issueReadCondition, issueResultsOnly: true,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
@@ -1409,10 +1410,13 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
|
||||
}) ?? fallback
|
||||
: fallback;
|
||||
if (!shouldReopen && watchdogIssue.originFingerprint !== input.classification.stopFingerprint) {
|
||||
await db
|
||||
await db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, input.watchdog.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ originFingerprint: input.classification.stopFingerprint, updatedAt: new Date() })
|
||||
.where(and(eq(issues.companyId, input.watchdog.companyId), eq(issues.id, watchdogIssue.id)));
|
||||
});
|
||||
watchdogIssue.originFingerprint = input.classification.stopFingerprint;
|
||||
}
|
||||
await issuesSvc.addComment(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { insertIssueComment } from "./issue-comment-writes.js";
|
||||
import { restoreGitWorkspaceContents } from "./git-workspace-contents.js";
|
||||
import { savedGitWorkspaceRestoration, ensureSavedGitCommit, GitCacheRestorationFailure } from "./git-workspace-restoration.js";
|
||||
@@ -1636,10 +1637,11 @@ async function writeDirtyQuarantineAuditComments(input: {
|
||||
let sourceAuditCommentId: string | null = null;
|
||||
let claimantAuditCommentId: string | null = null;
|
||||
const now = new Date();
|
||||
if (input.evidence.sourceIssueId) {
|
||||
const sourceIssueId = input.evidence.sourceIssueId;
|
||||
if (sourceIssueId) {
|
||||
const [sourceComment] = await insertIssueComment(input.db, {
|
||||
companyId: input.companyId,
|
||||
issueId: input.evidence.sourceIssueId,
|
||||
issueId: sourceIssueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
authorType: "system",
|
||||
@@ -1647,10 +1649,10 @@ async function writeDirtyQuarantineAuditComments(input: {
|
||||
body,
|
||||
});
|
||||
sourceAuditCommentId = sourceComment?.id ?? null;
|
||||
await input.db
|
||||
.update(issues)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(issues.id, input.evidence.sourceIssueId));
|
||||
await input.db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await tx.update(issues).set({ updatedAt: now }).where(eq(issues.id, sourceIssueId));
|
||||
});
|
||||
}
|
||||
|
||||
const claimantIssueId = input.evidence.contention?.claimedByIssueId ?? null;
|
||||
@@ -1665,10 +1667,10 @@ async function writeDirtyQuarantineAuditComments(input: {
|
||||
body,
|
||||
});
|
||||
claimantAuditCommentId = claimantComment?.id ?? null;
|
||||
await input.db
|
||||
.update(issues)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(issues.id, claimantIssueId));
|
||||
await input.db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await tx.update(issues).set({ updatedAt: now }).where(eq(issues.id, claimantIssueId));
|
||||
});
|
||||
}
|
||||
|
||||
return { sourceAuditCommentId, claimantAuditCommentId };
|
||||
|
||||
Reference in new issue
Block a user