mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
Integrate recovery and status-card sleep owners for combined verification
Preserve both notification subscribers and all existing execution queues; retain the union of scoped execution identities. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
94ddb74c0c
56 files changed
+981
-25141
No files matched your search
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
@@ -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";
|
||||
@@ -28,12 +29,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 +82,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 +105,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -528,6 +529,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 () => []),
|
||||
@@ -791,6 +799,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,377 @@
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
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,
|
||||
heartbeatRuns, instanceSettings, issueComments, issues, 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[] = [];
|
||||
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(agents); await db.delete(companyMemberships); await db.delete(companies);
|
||||
});
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
async function start() {
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true,
|
||||
onError: error => errors.push(error) });
|
||||
await registerStatusCardEvaluationWork(coordinator, statusCardEvaluationService(db, { wakeup }), () => !draining).ready;
|
||||
}
|
||||
async function settled() {
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
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);
|
||||
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("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("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 settled(); 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.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("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("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();
|
||||
const failed = (await statusCardService(db).getById(created.id))!;
|
||||
expect(failed).toMatchObject({ state: "error", generatingIssueId: null, nextEvalAt: 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(); }
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
filterStatusCardChanges,
|
||||
isWithinStatusCardActiveHours,
|
||||
nextStatusCardEvaluationAt,
|
||||
nextStatusCardPolicyEligibility,
|
||||
statusCardChangesHash,
|
||||
} from "../services/status-card-update-engine.js";
|
||||
|
||||
@@ -78,6 +79,28 @@ 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("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,3 +1,4 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
@@ -37,7 +38,8 @@ import {
|
||||
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 +81,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 +102,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
|
||||
+5
-30
@@ -145,13 +145,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";
|
||||
@@ -1574,7 +1573,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,
|
||||
@@ -1985,6 +1984,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);
|
||||
};
|
||||
|
||||
@@ -2015,33 +2016,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({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
@@ -745,6 +746,7 @@ export function accessService(db: Db) {
|
||||
await notifyNativeStatusReassessment(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 { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
@@ -269,6 +270,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
|
||||
@@ -389,6 +391,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
-1155
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "../../status-card-notifications.js";
|
||||
import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js";
|
||||
|
||||
import type { EndpointRow, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js";
|
||||
@@ -82,6 +83,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,559 +1 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { isRunnerApiResponseAsset } from "./asset-content-access.js";
|
||||
import { deleteCompany } from "./company-deletion.js";
|
||||
import { fenceActiveCompanyAgentStopsInTransaction } from "./agent-lifecycle.js";
|
||||
import { publishAccountingActivities } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companies,
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
issues,
|
||||
heartbeatRuns,
|
||||
costEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { isCloudManagedInstance } from "./cloud-instance.js";
|
||||
import { notifyCloudOfPrimaryCompanyLifecycleChange } from "./cloud-lifecycle-sync.js";
|
||||
import {
|
||||
MAX_ISSUE_PREFIX_ATTEMPTS,
|
||||
deriveIssuePrefixBase,
|
||||
isIssuePrefixConflict,
|
||||
issuePrefixSuffixForAttempt,
|
||||
pickAvailableIssuePrefix,
|
||||
rekeyCompanyIssueIdentifiers,
|
||||
} from "./issue-prefix.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { logActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { builtInAgentService } from "./built-in-agents.js";
|
||||
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export interface CompanyActivityActor {
|
||||
actorType: "user" | "agent" | "system" | "plugin";
|
||||
actorId: string;
|
||||
agentId?: string | null;
|
||||
runId?: string | null;
|
||||
}
|
||||
|
||||
const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = {
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
};
|
||||
|
||||
export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
|
||||
const environmentsSvc = environmentService(db);
|
||||
const builtInAgents = builtInAgentService(db);
|
||||
|
||||
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
|
||||
const [agentCount] = await tx.select({ value: count() }).from(agents).where(and(eq(agents.companyId, id), notInArray(agents.status, ["paused", "terminated", "pending_approval"])));
|
||||
|
||||
const [runCount] = await tx
|
||||
.select({ value: count() })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.companyId, id),
|
||||
inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
|
||||
));
|
||||
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
error: "Cancelled because the company was archived",
|
||||
finishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(agentWakeupRequests.companyId, id),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
|
||||
isNull(agentWakeupRequests.runId),
|
||||
));
|
||||
|
||||
return { agentsPauseRequested: agentCount.value, runsCancellationRequested: runCount.value };
|
||||
}
|
||||
|
||||
async function finalizeArchive(
|
||||
id: string,
|
||||
actor: CompanyActivityActor,
|
||||
cascade: { agentsPauseRequested: number; runsCancellationRequested: number },
|
||||
) {
|
||||
// The committed company status blocks dispatch immediately. Lifecycle
|
||||
// projection owns provider termination, including paused agents with work.
|
||||
await logActivity(db, {
|
||||
companyId: id,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId ?? null,
|
||||
runId: actor.runId ?? null,
|
||||
action: "company.archived",
|
||||
entityType: "company",
|
||||
entityId: id,
|
||||
details: {
|
||||
agentsPauseRequested: cascade.agentsPauseRequested,
|
||||
runsCancellationRequested: cascade.runsCancellationRequested,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const companySelection = {
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
description: companies.description,
|
||||
status: companies.status,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
defaultResponsibleUserId: companies.defaultResponsibleUserId,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
interactionResolverGovernance: companies.interactionResolverGovernance,
|
||||
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
||||
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
|
||||
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
createdAt: companies.createdAt,
|
||||
updatedAt: companies.updatedAt,
|
||||
};
|
||||
|
||||
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
||||
return {
|
||||
...company,
|
||||
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMonthlySpendByCompanyIds(
|
||||
companyIds: string[],
|
||||
database: Pick<Db, "select"> = db,
|
||||
) {
|
||||
if (companyIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await database
|
||||
.select({
|
||||
companyId: costEvents.companyId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
inArray(costEvents.companyId, companyIds),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.companyId);
|
||||
return new Map(rows.map((row) => [row.companyId, Number(row.spentMonthlyCents ?? 0)]));
|
||||
}
|
||||
|
||||
async function hydrateCompanySpend<T extends { id: string; spentMonthlyCents: number }>(
|
||||
rows: T[],
|
||||
database: Pick<Db, "select"> = db,
|
||||
) {
|
||||
const spendByCompanyId = await getMonthlySpendByCompanyIds(rows.map((row) => row.id), database);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
spentMonthlyCents: spendByCompanyId.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function getCompanyQuery(database: Pick<Db, "select">) {
|
||||
return database
|
||||
.select(companySelection)
|
||||
.from(companies)
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a rename must move the company onto a new issue prefix, and
|
||||
* returns the exact prefix pair to re-key.
|
||||
*
|
||||
* Self-hosted companies pick their prefix from the name at creation and keep
|
||||
* it, so a rename leaves the prefix alone. On a hosted/managed instance the
|
||||
* company is provisioned for the operator, so the name is the only prefix
|
||||
* source the operator ever chose — a rename re-derives it. Returns null when
|
||||
* the current prefix is already correct or when the suffix space is
|
||||
* exhausted.
|
||||
*/
|
||||
async function resolveRenamedIssuePrefix(
|
||||
tx: CompanyTx,
|
||||
companyId: string,
|
||||
companyPatch: Partial<typeof companies.$inferInsert>,
|
||||
): Promise<{ fromPrefix: string; toPrefix: string } | null> {
|
||||
// Only patch and environment facts gate the lock. Every comparison against
|
||||
// the company's own name or prefix happens below, under the lock.
|
||||
// An explicit prefix in the patch is the caller's decision; never override it.
|
||||
if (companyPatch.issuePrefix !== undefined) return null;
|
||||
const nextName = companyPatch.name;
|
||||
if (typeof nextName !== "string" || nextName.trim().length === 0) return null;
|
||||
if (!isCloudManagedInstance()) return null;
|
||||
|
||||
// Lock the company row before comparing anything against it. Two concurrent
|
||||
// updates would otherwise each decide from the row they read before either
|
||||
// committed, and both ways of getting that wrong end with a company whose
|
||||
// prefix disagrees with its own identifiers:
|
||||
//
|
||||
// - Two renames: the second re-keys from the prefix it read, finds the
|
||||
// identifiers the first already moved, and leaves them on the first
|
||||
// rename's prefix while the row carries the second one's.
|
||||
// - A rename plus a stale form that resubmits the original name: the
|
||||
// second sees a name equal to the one it read, skips re-derivation, and
|
||||
// restores the old name on top of the first rename's prefix.
|
||||
//
|
||||
// Reading the row under the lock makes the second transaction decide from
|
||||
// what the first actually committed. Only a managed instance takes this
|
||||
// lock, and only for an update that carries a name.
|
||||
const locked = await tx
|
||||
.select({ name: companies.name, issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!locked || nextName === locked.name) return null;
|
||||
|
||||
const nextBase = deriveIssuePrefixBase(nextName);
|
||||
// A rename that keeps the same base keeps the current prefix, including
|
||||
// any disambiguating suffix it was allocated.
|
||||
if (nextBase === deriveIssuePrefixBase(locked.name)) return null;
|
||||
if (nextBase === locked.issuePrefix) return null;
|
||||
|
||||
const candidate = await pickAvailableIssuePrefix(tx, nextBase);
|
||||
if (!candidate || candidate === locked.issuePrefix) return null;
|
||||
return { fromPrefix: locked.issuePrefix, toPrefix: candidate };
|
||||
}
|
||||
|
||||
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
|
||||
const base = deriveIssuePrefixBase(data.name);
|
||||
let suffix = 1;
|
||||
while (suffix <= MAX_ISSUE_PREFIX_ATTEMPTS) {
|
||||
const candidate = `${base}${issuePrefixSuffixForAttempt(suffix)}`;
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(companies)
|
||||
.values({ ...data, issuePrefix: candidate })
|
||||
.returning();
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
if (!isIssuePrefixConflict(error)) throw error;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
throw new Error("Unable to allocate unique issue prefix");
|
||||
}
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
const rows = await getCompanyQuery(db);
|
||||
const hydrated = await hydrateCompanySpend(rows);
|
||||
return hydrated.map((row) => enrichCompany(row));
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
// Non-UUID refs previously reached the uuid-typed query and threw a
|
||||
// DrizzleQueryError ("invalid input syntax for type uuid"), surfacing
|
||||
// as HTTP 500 from GET /api/companies/:companyId. Treat them as
|
||||
// not-found so the route returns 404.
|
||||
if (!UUID_RE.test(id)) return null;
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [hydrated] = await hydrateCompanySpend([row], db);
|
||||
return enrichCompany(hydrated);
|
||||
},
|
||||
|
||||
create: async (data: typeof companies.$inferInsert) => {
|
||||
const created = await createCompanyWithUniquePrefix(data);
|
||||
await environmentsSvc.ensureLocalEnvironment(created.id);
|
||||
await builtInAgents.autoProvisionBundledAgents(created.id);
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, created.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found after creation");
|
||||
const [hydrated] = await hydrateCompanySpend([row], db);
|
||||
return enrichCompany(hydrated);
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
|
||||
) => {
|
||||
const budgetPublications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (tx) => {
|
||||
if (data.status !== undefined || data.budgetMonthlyCents !== undefined) {
|
||||
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, id)).for("no key update");
|
||||
}
|
||||
const existing = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const { logoAssetId, ...companyPatch } = data;
|
||||
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
|
||||
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
|
||||
|
||||
if (logoAssetId !== undefined && logoAssetId !== null) {
|
||||
const nextLogoAsset = await tx
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.id, logoAssetId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
||||
if (await isRunnerApiResponseAsset(tx, nextLogoAsset)) {
|
||||
throw unprocessable("API response snapshots cannot be company logos; upload a logo asset");
|
||||
}
|
||||
if (nextLogoAsset.companyId !== existing.id) {
|
||||
throw unprocessable("Logo asset must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
const renamedPrefix = await resolveRenamedIssuePrefix(tx, id, companyPatch);
|
||||
|
||||
if (companyPatch.status !== undefined && companyPatch.status !== existing.status) {
|
||||
await notifyRunDispatch(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyCompanyRetentionWork(tx, id);
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
||||
}
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({
|
||||
...companyPatch,
|
||||
...(renamedPrefix ? { issuePrefix: renamedPrefix.toPrefix } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
let issuePrefixRederived: {
|
||||
previousIssuePrefix: string;
|
||||
issuePrefix: string;
|
||||
issuesRekeyed: number;
|
||||
casesRekeyed: number;
|
||||
} | null = null;
|
||||
if (renamedPrefix) {
|
||||
const rekeyed = await rekeyCompanyIssueIdentifiers(tx, {
|
||||
companyId: id,
|
||||
fromPrefix: renamedPrefix.fromPrefix,
|
||||
toPrefix: renamedPrefix.toPrefix,
|
||||
});
|
||||
issuePrefixRederived = {
|
||||
previousIssuePrefix: renamedPrefix.fromPrefix,
|
||||
issuePrefix: renamedPrefix.toPrefix,
|
||||
issuesRekeyed: rekeyed.issues,
|
||||
casesRekeyed: rekeyed.cases,
|
||||
};
|
||||
}
|
||||
|
||||
let agentsRestored = 0;
|
||||
if (willReactivate) {
|
||||
const restoredRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), eq(agents.pauseReason, "company_archived")));
|
||||
agentsRestored = restoredRows.length;
|
||||
}
|
||||
|
||||
const archiveCascade = willArchive ? await applyArchiveCascadeInTx(tx, id) : null;
|
||||
if (companyPatch.status !== existing.status && (companyPatch.status === "paused" || companyPatch.status === "archived")) {
|
||||
await fenceActiveCompanyAgentStopsInTransaction(tx as unknown as Db, id, companyPatch.status);
|
||||
}
|
||||
|
||||
if (logoAssetId === null) {
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
} else if (logoAssetId !== undefined) {
|
||||
await tx
|
||||
.insert(companyLogos)
|
||||
.values({
|
||||
companyId: id,
|
||||
assetId: logoAssetId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: companyLogos.companyId,
|
||||
set: {
|
||||
assetId: logoAssetId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
||||
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||
}
|
||||
|
||||
if (data.budgetMonthlyCents !== undefined) {
|
||||
await budgetServiceInTransaction(tx as unknown as Db, budgetPublications).upsertPolicy(id, {
|
||||
scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, isActive: data.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
|
||||
}, actor.actorType === "user" ? actor.actorId : null);
|
||||
const [budgetUpdated] = await tx.select().from(companies).where(eq(companies.id, id));
|
||||
Object.assign(updated, budgetUpdated);
|
||||
}
|
||||
const [hydrated] = await hydrateCompanySpend([{
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
}], tx);
|
||||
|
||||
const shouldLogReactivation = willReactivate &&
|
||||
(existing.status === "archived" || agentsRestored > 0);
|
||||
|
||||
return {
|
||||
company: enrichCompany(hydrated),
|
||||
reactivated: shouldLogReactivation ? { agentsRestored } : null,
|
||||
archiveCascade,
|
||||
unarchived: willReactivate && existing.status === "archived",
|
||||
issuePrefixRederived,
|
||||
};
|
||||
});
|
||||
if (!result) return null;
|
||||
publishAccountingActivities(id, budgetPublications);
|
||||
// Post-commit, fire-and-forget, and BEFORE any finalization that
|
||||
// could throw: a Cloud-pinned primary company that crossed the
|
||||
// archived boundary (either direction) rings the harness so the
|
||||
// stack itself can converge. The status transaction has already
|
||||
// committed, so a later cascade or activity-log failure must not
|
||||
// leave Cloud unaware of a company that is in fact archived.
|
||||
if (result.archiveCascade || result.unarchived) {
|
||||
void notifyCloudOfPrimaryCompanyLifecycleChange(id);
|
||||
}
|
||||
if (result.issuePrefixRederived) {
|
||||
await logActivity(db, {
|
||||
companyId: id,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId ?? null,
|
||||
runId: actor.runId ?? null,
|
||||
action: "company.updated",
|
||||
entityType: "company",
|
||||
entityId: id,
|
||||
details: {
|
||||
source: "company_rename",
|
||||
reason: "issue_prefix_rederived",
|
||||
...result.issuePrefixRederived,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (result.reactivated) {
|
||||
await logActivity(db, {
|
||||
companyId: id,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId ?? null,
|
||||
runId: actor.runId ?? null,
|
||||
action: "company.reactivated",
|
||||
entityType: "company",
|
||||
entityId: id,
|
||||
details: { agentsRestored: result.reactivated.agentsRestored },
|
||||
});
|
||||
}
|
||||
if (result.archiveCascade) {
|
||||
await finalizeArchive(id, actor, result.archiveCascade);
|
||||
}
|
||||
return result.company;
|
||||
},
|
||||
|
||||
archive: async (id: string, actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR) => {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({ status: companies.status })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const wasAlreadyArchived = existing.status === "archived";
|
||||
|
||||
if (!wasAlreadyArchived) {
|
||||
await notifyRunDispatch(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyCompanyRetentionWork(tx, id);
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
|
||||
await tx
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id));
|
||||
}
|
||||
|
||||
const cascade = wasAlreadyArchived ? null : await applyArchiveCascadeInTx(tx, id);
|
||||
if (!wasAlreadyArchived) await fenceActiveCompanyAgentStopsInTransaction(tx as unknown as Db, id, "archived");
|
||||
|
||||
const row = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [hydrated] = await hydrateCompanySpend([row], tx);
|
||||
return {
|
||||
company: enrichCompany(hydrated),
|
||||
cascade,
|
||||
};
|
||||
});
|
||||
if (!result) return null;
|
||||
|
||||
// Same doorbell rule as update(): the archive is committed, so ring
|
||||
// before finalization, which can throw without undoing it.
|
||||
if (result.cascade) {
|
||||
void notifyCloudOfPrimaryCompanyLifecycleChange(id);
|
||||
await finalizeArchive(id, actor, result.cascade);
|
||||
}
|
||||
|
||||
return result.company;
|
||||
},
|
||||
|
||||
remove: (id: string) => deleteCompany(db, id),
|
||||
|
||||
stats: () =>
|
||||
Promise.all([
|
||||
db
|
||||
.select({ companyId: agents.companyId, count: count() })
|
||||
.from(agents)
|
||||
.groupBy(agents.companyId),
|
||||
db
|
||||
.select({ companyId: issues.companyId, count: count() })
|
||||
.from(issues)
|
||||
.groupBy(issues.companyId),
|
||||
]).then(([agentRows, issueRows]) => {
|
||||
const result: Record<string, { agentCount: number; issueCount: number }> = {};
|
||||
for (const row of agentRows) {
|
||||
result[row.companyId] = { agentCount: row.count, issueCount: 0 };
|
||||
}
|
||||
for (const row of issueRows) {
|
||||
if (result[row.companyId]) {
|
||||
result[row.companyId].issueCount = row.count;
|
||||
} else {
|
||||
result[row.companyId] = { agentCount: 0, issueCount: row.count };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
}
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
||||
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
|
||||
@@ -77,6 +78,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,7 @@ 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 },
|
||||
): Promise<CompanySearchResponse> => {
|
||||
const taskSearch = parseTaskSearch(query.q);
|
||||
const normalizedQuery = taskSearch.normalizedQuery;
|
||||
@@ -553,6 +559,7 @@ export function companySearchService(db: Db) {
|
||||
const limit = query.limit;
|
||||
const offset = query.offset;
|
||||
if (!hasSearchText && !issueOnlyFiltersActive(query)) {
|
||||
options?.onNextMembershipChange?.(null);
|
||||
return {
|
||||
query: query.q,
|
||||
normalizedQuery,
|
||||
@@ -721,6 +728,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 +746,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 +771,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 +794,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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -7340,6 +7341,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 })
|
||||
|
||||
@@ -2,135 +2,4 @@ import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
|
||||
|
||||
/** Topics for existing durable queues; register intent before their writes. */
|
||||
export const DELIVERY_QUEUES = {
|
||||
scheduledRetryDispatch: "scheduled-retry-dispatch",
|
||||
queuedRunDispatch: "queued-run-dispatch",
|
||||
runEvidenceMigration: "run-evidence-migration",
|
||||
providerTraceRetention: "provider-trace-retention",
|
||||
nativeRunnerEvidenceRetention: "native-runner-evidence-retention",
|
||||
skillVersionRetention: "skill-version-retention",
|
||||
databaseBackups: "database-backups",
|
||||
importSpoolCleanup: "import-spool-cleanup",
|
||||
externalConversationSettlement: "external-conversation-settlement",
|
||||
nativeRecoveryNotices: "native-recovery-notices",
|
||||
nativeEnvironmentSettlement: "native-environment-settlement",
|
||||
nativeRunProjection: "native-run-projection",
|
||||
nativeResultCommit: "native-result-commit",
|
||||
nativeWorkspaceFinalization: "native-workspace-finalization",
|
||||
retainedNativeSessionCleanup: "retained-native-session-cleanup",
|
||||
nativeStatusReassessment: "native-status-reassessment",
|
||||
nativeReviewResponse: "native-review-response",
|
||||
nativeResponsePresentation: "native-response-presentation",
|
||||
nativePendingEffects: "native-pending-effects",
|
||||
unsafeWorkspaceExportRecovery: "unsafe-workspace-export-recovery",
|
||||
automaticCompletionReviews: "automatic-completion-reviews",
|
||||
obsoletePolicyReviews: "obsolete-policy-reviews",
|
||||
cancelledWorkspacePreservation: "cancelled-workspace-preservation",
|
||||
instructionCollection: "instruction-collection",
|
||||
directoryCleanup: "directory-cleanup",
|
||||
instructionCommit: "instruction-commit",
|
||||
scheduledRetryCancellation: "scheduled-retry-cancellation",
|
||||
executionFinalization: "execution-finalization",
|
||||
workspaceRecovery: "workspace-recovery",
|
||||
executionReplacement: "execution-replacement",
|
||||
executionContinuation: "execution-continuation",
|
||||
executionStatus: "execution-status",
|
||||
executionDisposition: "execution-disposition",
|
||||
environmentCleanup: "environment-cleanup",
|
||||
approvalQueueSeeding: "approval-queue-seeding",
|
||||
approvalRetention: "approval-retention",
|
||||
joinRequestRetention: "join-request-retention",
|
||||
decisionTargets: "decision-targets",
|
||||
decisionEffectRecovery: "decision-effect-recovery",
|
||||
decisionExpiry: "decision-expiry",
|
||||
decisionContinuation: "decision-continuation",
|
||||
|
||||
decisionArchiveDelivery: "decision-archive-delivery",
|
||||
toolConnectionHealth: "tool-connection-health",
|
||||
toolReviewProjection: "tool-review-projection",
|
||||
toolReviewInterruption: "tool-review-interruption",
|
||||
toolReviewRecovery: "tool-review-recovery",
|
||||
toolReviewExpiry: "tool-review-expiry",
|
||||
gatewayCredentialExpiry: "gateway-credential-expiry",
|
||||
secretProposalExpiry: "secret-proposal-expiry",
|
||||
customImageExpiry: "custom-image-expiry",
|
||||
historicalCleanup: "historical-cleanup",
|
||||
adapterLoginCleanup: "adapter-login-cleanup",
|
||||
setupTokenCleanup: "setup-token-cleanup",
|
||||
localLoginCleanup: "local-login-cleanup",
|
||||
budgetEnforcement: "budget-enforcement",
|
||||
accounting: "cost-accounting",
|
||||
taskDrainExpiry: "task-drain-expiry",
|
||||
agentLifecycle: "agent-lifecycle",
|
||||
chatBoardMessages: "chat-board-messages",
|
||||
chatTaskStarts: "chat-task-starts",
|
||||
chatReceiptReactions: "chat-receipt-reactions",
|
||||
chatSessionStops: "chat-session-stops",
|
||||
chatProviderEffects: "chat-provider-effects",
|
||||
chatGitHubWebhookRecovery: "chat-github-webhook-recovery",
|
||||
chatGitHubIngress: "chat-github-ingress",
|
||||
chatGitHubPublications: "chat-github-publications",
|
||||
chatGitHubReviewProjection: "chat-github-review-projection",
|
||||
chatGitHubReviewChecks: "chat-github-review-checks",
|
||||
chatGitHubTerminalResponses: "chat-github-terminal-responses",
|
||||
chatGitHubAcknowledgements: "chat-github-acknowledgements",
|
||||
chatFailedRunRetries: "chat-failed-run-retries",
|
||||
chatTelegramMaintenance: "chat-telegram-maintenance",
|
||||
chatReceivers: "chat-receivers",
|
||||
voiceSessionRecovery: "voice-session-recovery",
|
||||
voiceReports: "voice-call-reports",
|
||||
voiceInboundRetention: "voice-inbound-retention",
|
||||
voiceInboundCleanup: "voice-inbound-cleanup",
|
||||
voiceReplies: "voice-reply-delivery",
|
||||
chatTeamsConsentExpiry: "chat-teams-consent-expiry",
|
||||
chatTeamsAttemptRecovery: "chat-teams-attempt-recovery",
|
||||
chatCommandRegistration: "chat-command-registration",
|
||||
chatTelegramSubscriptions: "chat-telegram-subscriptions",
|
||||
chatSessionSyncs: "chat-session-syncs",
|
||||
chatVerification: "chat-verification",
|
||||
chatInbound: "chat-inbound",
|
||||
chatPublications: "chat-publications",
|
||||
chatNativeProgress: "chat-native-progress",
|
||||
chatInboundWakeNotices: "chat-inbound-wake-notices",
|
||||
chatInteractionContinuations: "chat-interaction-continuations",
|
||||
chatTerminalConfirmations: "chat-terminal-confirmations",
|
||||
chatFailedRetryNotices: "chat-failed-retry-notices",
|
||||
chatRunMilestones: "chat-run-milestones",
|
||||
chatReceipts: "chat-slack-receipts",
|
||||
browser: "browser-use",
|
||||
email: "email-channels",
|
||||
museMaintenance: "muse-maintenance",
|
||||
mcpEvents: "mcp-events",
|
||||
dotEvents: "dot-events",
|
||||
grokDispatch: "grok-dispatch",
|
||||
skillSourceSync: "skill-source-sync",
|
||||
skillPackages: "skill-packages",
|
||||
runtimeAssetRetention: "runtime-asset-retention",
|
||||
fastResponse: "fast-responses",
|
||||
feedback: "feedback-exports",
|
||||
chatCompletion: "chat-completions",
|
||||
connection: "connection-continuations",
|
||||
gitRestorePoints: "git-restore-points",
|
||||
nativeWorkspaceSyncRetention: "native-workspace-sync-retention",
|
||||
question: "question-responses",
|
||||
pluginSchedules: "plugin-schedules",
|
||||
pluginLifecycle: "plugin-resource-lifecycle",
|
||||
toolAction: "tool-action-receipts",
|
||||
terminalWorkspaces: "terminal-workspaces",
|
||||
routineSchedules: "routine-schedules",
|
||||
issueMonitors: "issue-monitors",
|
||||
agentTimers: "agent-timers",
|
||||
caseHandoffs: "case-handoffs",
|
||||
nativeCheckpointExpiration: "native-checkpoint-expiration",
|
||||
} as const;
|
||||
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
|
||||
|
||||
export async function notifyDeliveryWork(transaction: object, queue: DeliveryQueue, hint?: string): Promise<void> {
|
||||
await signalDatabaseWork(transaction, queue, hint);
|
||||
}
|
||||
// Public delivery notification observers only see settled work. The coordinator
|
||||
// uses the lower-level lifecycle subscription to also fence in-flight writes.
|
||||
export function subscribeDeliveryWork(owner: object, queue: DeliveryQueue, wake: () => void): () => void {
|
||||
return subscribeDatabaseWork(owner, queue, event => {
|
||||
if (event === "settled") wake();
|
||||
});
|
||||
}
|
||||
statusCardEvaluation: "status-card-evaluation",
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -238,6 +239,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({
|
||||
@@ -592,6 +594,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 insertRowsInChunks(tx, documents, documentRows);
|
||||
await insertRowsInChunks(tx, documentRevisions, revisionRows);
|
||||
await insertRowsInChunks(tx, issueDocuments, linkRows);
|
||||
@@ -662,6 +665,7 @@ export function documentService(db: Db) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
await notifyStatusCardIssueChange(tx, existing.companyId);
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
@@ -818,6 +822,7 @@ export function documentService(db: Db) {
|
||||
}
|
||||
|
||||
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,27 +1 @@
|
||||
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 async function notifyExecutionRunWork(transaction: object, scope?: ExecutionWorkScope) {
|
||||
if (scope) await notifyRunDispatch(transaction, scope);
|
||||
for (const queue of [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization,
|
||||
DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors]) {
|
||||
await notifyDeliveryWork(transaction, queue);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeExecutionControl<T>(
|
||||
db: Db | Parameters<Parameters<Db["transaction"]>[0]>[0],
|
||||
queue: DeliveryQueue | readonly DeliveryQueue[],
|
||||
write: (tx: Db) => PromiseLike<T>,
|
||||
scope: ExecutionWorkScope | undefined,
|
||||
): Promise<T> {
|
||||
return db.transaction(async transaction => {
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.issueMonitors);
|
||||
for (const topic of typeof queue === "string" ? [queue] : queue) await notifyDeliveryWork(transaction, topic);
|
||||
return write(transaction as unknown as Db);
|
||||
});
|
||||
}
|
||||
if (scope) await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,669 +1 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifySkillVersionRetentionReconstruction } from "./skill-version-retention-notifications.js";
|
||||
import { notifyNativeEnvironmentSettlement } from "./native-environment-settlement-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../modules/external-agents/index.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
|
||||
import { notifyNativeRunProjection } from "./native-run-projection-notifications.js";
|
||||
import { notifyNativeResultCommit } from "./native-result-commit-notifications.js";
|
||||
import { notifyNativeWorkspaceFinalization } from "./native-workspace-finalization-notifications.js";
|
||||
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
||||
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
|
||||
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
|
||||
import { notifyInstructionCommit } from "./instruction-commit-notifications.js";
|
||||
import { notifyScheduledRetryCancellation } from "./scheduled-retry-cancellation-notifications.js";
|
||||
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
|
||||
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
|
||||
/**
|
||||
* A `Db` or an open transaction handle — the subset of query builders the
|
||||
* settings writes use. Lets `update` run inside a caller's transaction so
|
||||
* it commits atomically with a sibling write.
|
||||
*/
|
||||
type InstanceSettingsTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
export type InstanceSettingsWriteDb = Pick<
|
||||
Db | InstanceSettingsTransaction,
|
||||
"select" | "insert" | "update"
|
||||
>;
|
||||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
instanceGeneralSettingsSchema,
|
||||
type InstanceGeneralSettings,
|
||||
instanceExperimentalSettingsSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type InstanceExperimentalSettingsWithManaged,
|
||||
type ManagedExperimentalFeatureKey,
|
||||
type ManagedSettingMetadata,
|
||||
type PatchInstanceGeneralSettings,
|
||||
type InstanceSettings,
|
||||
type PatchInstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
INSTANCE_FEATURE_CATALOG,
|
||||
applyOperatorGeneralDefaults,
|
||||
stripOperatorGeneralEchoes,
|
||||
} from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js";
|
||||
import { getOperatorSettingDefaults } from "./setting-defaults.js";
|
||||
import { isCloudStagingExperimentalSetting, isCloudStagingInstance } from "./cloud-instance.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
const DEFAULT_SINGLETON_KEY = "default";
|
||||
const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
|
||||
const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
|
||||
const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
interface InstanceSettingsServiceOptions {
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
type WorktreeRunExecutionSuppressedReason =
|
||||
| "not_worktree_runtime"
|
||||
| "flag_disabled"
|
||||
| "missing_cutoff"
|
||||
| "missing_instance_id"
|
||||
| "instance_id_mismatch"
|
||||
| "settings_read_error";
|
||||
|
||||
export type WorktreeRunExecutionActivationState =
|
||||
| {
|
||||
armed: true;
|
||||
cutoff: string;
|
||||
activationInstanceId: string;
|
||||
reason: null;
|
||||
}
|
||||
| {
|
||||
armed: false;
|
||||
cutoff: null;
|
||||
activationInstanceId: string | null;
|
||||
reason: WorktreeRunExecutionSuppressedReason;
|
||||
};
|
||||
|
||||
export function isTruthyRuntimeEnvValue(value: string | undefined) {
|
||||
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getRuntimeInstanceId(env: Record<string, string | undefined>) {
|
||||
const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim();
|
||||
return instanceId ? instanceId : null;
|
||||
}
|
||||
|
||||
function stripServerManagedExperimentalPatchFields(
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
): PatchInstanceExperimentalSettings {
|
||||
const {
|
||||
worktreeRunExecutionActivatedAt: _ignoredActivatedAt,
|
||||
worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId,
|
||||
...patchable
|
||||
} = patch as Record<string, unknown>;
|
||||
return patchable as PatchInstanceExperimentalSettings;
|
||||
}
|
||||
|
||||
export function applyExperimentalSettingsPatch(
|
||||
current: unknown,
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
options: InstanceSettingsServiceOptions = {},
|
||||
): InstanceExperimentalSettings {
|
||||
const previousExperimental = normalizeExperimentalSettings(current);
|
||||
const patchable = stripServerManagedExperimentalPatchFields(patch);
|
||||
const nextExperimental = normalizeExperimentalSettings({
|
||||
...previousExperimental,
|
||||
...patchable,
|
||||
});
|
||||
const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call(
|
||||
patchable,
|
||||
"enableWorktreeRunExecution",
|
||||
);
|
||||
|
||||
if (!hasWorktreeRunExecutionPatch) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
if (nextExperimental.enableWorktreeRunExecution !== true) {
|
||||
return {
|
||||
...nextExperimental,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (previousExperimental.enableWorktreeRunExecution === true) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
const runtimeEnv = options.runtimeEnv ?? process.env;
|
||||
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
||||
return nextExperimental;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextExperimental,
|
||||
worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
||||
worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv),
|
||||
};
|
||||
}
|
||||
|
||||
function suppressWorktreeRunExecution(
|
||||
reason: WorktreeRunExecutionSuppressedReason,
|
||||
activationInstanceId: string | null = null,
|
||||
): WorktreeRunExecutionActivationState {
|
||||
return {
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
activationInstanceId,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorktreeRunExecutionActivation(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
currentInstanceId: string | null | undefined,
|
||||
): WorktreeRunExecutionActivationState {
|
||||
if (experimental.enableWorktreeRunExecution !== true) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"flag_disabled",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (!experimental.worktreeRunExecutionActivatedAt) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"missing_cutoff",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (!currentInstanceId) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"missing_instance_id",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
|
||||
return suppressWorktreeRunExecution(
|
||||
"instance_id_mismatch",
|
||||
experimental.worktreeRunExecutionActivationInstanceId,
|
||||
);
|
||||
}
|
||||
return {
|
||||
armed: true,
|
||||
cutoff: experimental.worktreeRunExecutionActivatedAt,
|
||||
activationInstanceId: currentInstanceId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveWorktreeRunExecutionActivationState(options: {
|
||||
getExperimental: () => Promise<InstanceExperimentalSettings>;
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
}): Promise<WorktreeRunExecutionActivationState> {
|
||||
const runtimeEnv = options.runtimeEnv ?? process.env;
|
||||
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
|
||||
return suppressWorktreeRunExecution("not_worktree_runtime");
|
||||
}
|
||||
try {
|
||||
return resolveWorktreeRunExecutionActivation(
|
||||
await options.getExperimental(),
|
||||
getRuntimeInstanceId(runtimeEnv),
|
||||
);
|
||||
} catch {
|
||||
return suppressWorktreeRunExecution("settings_read_error");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
||||
const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
|
||||
feedbackDataSharingPreference:
|
||||
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
|
||||
// Absent => unrestricted; only carry through an explicit policy.
|
||||
...(parsed.data.executionMode ? { executionMode: parsed.data.executionMode } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
backupRetention: DEFAULT_BACKUP_RETENTION,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
|
||||
const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
|
||||
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
|
||||
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true,
|
||||
// Apps graduated from Experimental. Ignore historical off values while
|
||||
// continuing to accept the compatibility key in stored settings.
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
|
||||
enableOpenAiDot: parsed.data.enableOpenAiDot ?? false,
|
||||
enableMuse: parsed.data.enableMuse ?? false,
|
||||
enableGrokBot: parsed.data.enableGrokBot ?? false,
|
||||
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
||||
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
|
||||
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
|
||||
enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false,
|
||||
enableCases: parsed.data.enableCases ?? false,
|
||||
enableAgentChat: parsed.data.enableAgentChat ?? false,
|
||||
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
|
||||
enableArtifactsV1: parsed.data.enableArtifactsV1 ?? false,
|
||||
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
||||
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
|
||||
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
||||
enableBetaSkills: parsed.data.enableBetaSkills ?? false,
|
||||
enableSummaries: parsed.data.enableSummaries ?? false,
|
||||
enableStatusCards: parsed.data.enableStatusCards ?? false,
|
||||
enableDecisions: parsed.data.enableDecisions ?? false,
|
||||
enableFastResponses: parsed.data.enableFastResponses ?? false,
|
||||
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
|
||||
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
|
||||
enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false,
|
||||
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
|
||||
enableFirstTaskPlanProposal: parsed.data.enableFirstTaskPlanProposal ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
|
||||
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
|
||||
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
|
||||
enableSandboxDuplexBridge: parsed.data.enableSandboxDuplexBridge ?? false,
|
||||
enableRunnerPreviewIngress: parsed.data.enableRunnerPreviewIngress ?? false,
|
||||
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
|
||||
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
|
||||
worktreeRunExecutionActivationInstanceId:
|
||||
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableNativeRunner: true,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
enablePaperclipNotebook: false,
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableSmokeLab: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableBetaSkills: false,
|
||||
enableSummaries: false,
|
||||
enableStatusCards: false,
|
||||
enableDecisions: false,
|
||||
enableFastResponses: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
enablePaperclipDeveloperMode: false,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
enableFirstTaskPlanProposal: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableOwnerInstanceAdmin: false,
|
||||
enableSandboxDuplexBridge: false,
|
||||
enableRunnerPreviewIngress: false,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ManagedExperimentalKeyMetadata = Partial<
|
||||
Record<ManagedExperimentalFeatureKey, ManagedSettingMetadata>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Overlay the cloud managed-config feature values over normalized settings.
|
||||
*
|
||||
* Read-time precedence: code floor (cloud) > managed overlay > tenant DB
|
||||
* value > schema default. (No code floors are expressed as flags today —
|
||||
* floors are enforced in code at the guarded routes, independent of any
|
||||
* flag value.) The overlay is deliberately never persisted: it re-asserts on
|
||||
* every read, so a DB restore or manual row edit cannot resurrect a
|
||||
* capability the harness has disabled.
|
||||
*/
|
||||
export function applyManagedExperimentalOverlay(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): { experimental: InstanceExperimentalSettings; managedKeys: ManagedExperimentalKeyMetadata } {
|
||||
if (!managedConfig) return { experimental, managedKeys: {} };
|
||||
const next: InstanceExperimentalSettings = { ...experimental };
|
||||
const managedKeys: ManagedExperimentalKeyMetadata = {};
|
||||
for (const [key, value] of Object.entries(managedConfig.features) as Array<
|
||||
[ManagedExperimentalFeatureKey, boolean]
|
||||
>) {
|
||||
// Existing Cloud stack configs may still carry retired flags. Accept the
|
||||
// document during rollout, but never let retired flags disable Apps or MCP aggregators.
|
||||
if (key === "enableApps" || key === "enableMcpAggregators") continue;
|
||||
next[key] = value;
|
||||
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
|
||||
}
|
||||
return { experimental: next, managedKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep self-hosted-only defaults out of Cloud.
|
||||
*
|
||||
* The experimental schema carries one default per flag, and the feature
|
||||
* catalog pins it to `selfHostedDefault`. A flag that is on by default for
|
||||
* self-hosted but off by default for Cloud (`selfHostedDefault: true`,
|
||||
* `cloudDefault: false`) would therefore normalize to "on" for a managed
|
||||
* instance whose tenant row and managed overlay both leave it unset. Re-assert
|
||||
* the declared Cloud default for exactly those flags. An explicit tenant value
|
||||
* or a managed feature value still wins (the overlay is applied afterwards).
|
||||
*/
|
||||
export function applyCloudCatalogDefaults(
|
||||
experimental: InstanceExperimentalSettings,
|
||||
rawStored: unknown,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): InstanceExperimentalSettings {
|
||||
if (!managedConfig) return experimental;
|
||||
const stored =
|
||||
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
||||
? (rawStored as Record<string, unknown>)
|
||||
: {};
|
||||
const next: InstanceExperimentalSettings = { ...experimental };
|
||||
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
||||
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
||||
if (typeof stored[key] === "boolean") continue;
|
||||
if (typeof managedConfig.features[key as ManagedExperimentalFeatureKey] === "boolean") continue;
|
||||
(next as unknown as Record<string, unknown>)[key] = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the write path from freezing a self-hosted default into a Cloud row.
|
||||
*
|
||||
* `updateExperimental` persists the whole normalized object, and the schema
|
||||
* normalizes an omitted flag to its self-hosted default. Without this step an
|
||||
* unrelated experimental write (say, turning on Cases) would store
|
||||
* `enableNativeRunner: true` on a managed instance whose tenant row had never
|
||||
* mentioned the flag; every later read would then treat the stored boolean as
|
||||
* an explicit tenant choice and stop re-asserting the Cloud default.
|
||||
*
|
||||
* For each guarded flag (see `applyCloudCatalogDefaults`), the stored key is
|
||||
* left absent unless the tenant already stored a boolean or this patch sets
|
||||
* the flag to something other than the Cloud default. A patch value equal to
|
||||
* the Cloud default is a full-GET echo of the read-time overlay, not a
|
||||
* choice, and is stripped the same way `stripOperatorGeneralEchoes` treats
|
||||
* operator defaults. Self-hosted rows are returned untouched.
|
||||
*/
|
||||
export function stripCloudCatalogDefaultEchoes(
|
||||
rawStored: unknown,
|
||||
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
|
||||
next: InstanceExperimentalSettings,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): Partial<InstanceExperimentalSettings> {
|
||||
if (!managedConfig) return next;
|
||||
const stored =
|
||||
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
||||
? (rawStored as Record<string, unknown>)
|
||||
: {};
|
||||
const patchRecord = patch as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = { ...next };
|
||||
for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) {
|
||||
if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue;
|
||||
if (typeof stored[key] === "boolean") continue;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patchRecord, key) &&
|
||||
typeof patchRecord[key] === "boolean" &&
|
||||
patchRecord[key] !== entry.cloudDefault
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
delete result[key];
|
||||
}
|
||||
return result as Partial<InstanceExperimentalSettings>;
|
||||
}
|
||||
|
||||
export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) {
|
||||
// Fail closed: a malformed PAPERCLIP_MANAGED_CONFIG throws here (and at
|
||||
// boot in index.ts) rather than silently running without the overlay.
|
||||
const managedConfig = getManagedInstanceConfig(options.runtimeEnv ?? process.env);
|
||||
const staging = isCloudStagingInstance(options.runtimeEnv ?? process.env);
|
||||
const enforcedManagedConfig = staging && managedConfig
|
||||
? {
|
||||
...managedConfig,
|
||||
features: Object.fromEntries(Object.entries(managedConfig.features)
|
||||
.filter(([key, enabled]) => enabled || !isCloudStagingExperimentalSetting(key))),
|
||||
}
|
||||
: managedConfig;
|
||||
// Same posture for PAPERCLIP_SETTING_DEFAULTS: parsed once, applied per
|
||||
// read, never persisted (see applyOperatorGeneralDefaults) — including on
|
||||
// the write path, where a full-GET echo of the overlaid value is stripped
|
||||
// back to the schema default (see stripOperatorGeneralEchoes).
|
||||
const operatorDefaults = getOperatorSettingDefaults(options.runtimeEnv ?? process.env);
|
||||
|
||||
function toGeneralView(raw: unknown): InstanceGeneralSettings {
|
||||
return applyOperatorGeneralDefaults(normalizeGeneralSettings(raw), operatorDefaults);
|
||||
}
|
||||
|
||||
function toExperimentalView(raw: unknown): InstanceExperimentalSettingsWithManaged {
|
||||
const stored = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
||||
const defaults = staging ? { ...managedConfig?.features, ...stored } : raw;
|
||||
const { experimental, managedKeys } = applyManagedExperimentalOverlay(
|
||||
applyCloudCatalogDefaults(normalizeExperimentalSettings(defaults), defaults, managedConfig),
|
||||
enforcedManagedConfig,
|
||||
);
|
||||
// Self-hosted responses stay byte-identical: no managedKeys field at all.
|
||||
return managedConfig ? { ...experimental, managedKeys } : experimental;
|
||||
}
|
||||
|
||||
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId ?? null,
|
||||
general: toGeneralView(row.general),
|
||||
experimental: toExperimentalView(row.experimental),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
} as InstanceSettings;
|
||||
}
|
||||
async function getOrCreateRow(runner: InstanceSettingsWriteDb = db) {
|
||||
const existing = await runner
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const now = new Date();
|
||||
const [created] = await runner
|
||||
.insert(instanceSettings)
|
||||
.values({
|
||||
singletonKey: DEFAULT_SINGLETON_KEY,
|
||||
general: {},
|
||||
experimental: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [instanceSettings.singletonKey],
|
||||
set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (created) return created;
|
||||
|
||||
const raced = await runner
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (raced) return raced;
|
||||
|
||||
throw new Error("Failed to initialize instance settings row");
|
||||
}
|
||||
|
||||
return {
|
||||
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
update: async (
|
||||
patch: PatchInstanceSettings,
|
||||
writeOptions?: { db?: InstanceSettingsWriteDb },
|
||||
): Promise<InstanceSettings> => {
|
||||
// The write may run inside a caller-supplied transaction so it commits
|
||||
// atomically with a sibling write (e.g. clearing the managed-default
|
||||
// stamp on the environment row alongside a defaultEnvironmentId
|
||||
// change). Reads use the same runner so the row is visible to the tx.
|
||||
const runner = writeOptions?.db ?? db;
|
||||
const current = await getOrCreateRow(runner);
|
||||
const now = new Date();
|
||||
const [updated] = await runner
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
|
||||
? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
getGeneral: async (
|
||||
readOptions?: { db?: InstanceSettingsWriteDb },
|
||||
): Promise<InstanceGeneralSettings> => {
|
||||
const row = await getOrCreateRow(readOptions?.db);
|
||||
return toGeneralView(row.general);
|
||||
},
|
||||
|
||||
getExperimental: async (): Promise<InstanceExperimentalSettingsWithManaged> => {
|
||||
const row = await getOrCreateRow();
|
||||
return toExperimentalView(row.experimental);
|
||||
},
|
||||
|
||||
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const storedGeneral = normalizeGeneralSettings(current.general);
|
||||
// A full-GET echo carries the overlaid operator value for a field the
|
||||
// user never chose; stripping it keeps the overlay strictly read-time,
|
||||
// so changing or unsetting the variable later still takes effect.
|
||||
const nextGeneral = stripOperatorGeneralEchoes(
|
||||
storedGeneral,
|
||||
normalizeGeneralSettings({ ...storedGeneral, ...patch }),
|
||||
operatorDefaults,
|
||||
);
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
general: { ...nextGeneral },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
|
||||
for (const [key, enabled] of Object.entries(managedConfig?.features ?? {})) {
|
||||
if (enabled && patch[key as ManagedExperimentalFeatureKey] === false) {
|
||||
throw forbidden(`${key} is enabled by Paperclip Cloud and cannot be disabled on this instance`, {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
const current = await getOrCreateRow();
|
||||
// Guarded Cloud flags stay absent from the row unless chosen, so the
|
||||
// read-time catalog default keeps applying (see stripCloudCatalogDefaultEchoes).
|
||||
const nextExperimental = staging
|
||||
? applyExperimentalSettingsPatch(toExperimentalView(current.experimental), patch, options)
|
||||
: stripCloudCatalogDefaultEchoes(
|
||||
current.experimental,
|
||||
patch,
|
||||
applyExperimentalSettingsPatch(current.experimental, patch, options),
|
||||
managedConfig,
|
||||
);
|
||||
if (staging) {
|
||||
const stored = current.experimental as Record<string, unknown> | null;
|
||||
for (const key of Object.keys(INSTANCE_FEATURE_CATALOG) as ManagedExperimentalFeatureKey[]) {
|
||||
if (typeof stored?.[key] !== "boolean" && !Object.prototype.hasOwnProperty.call(patch, key)) {
|
||||
delete nextExperimental[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const [updated] = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.grokDispatch);
|
||||
if (patch.enableWorktreeRunExecution !== undefined) await notifyScheduledRetryCancellation(tx);
|
||||
if (patch.enableWorktreeRunExecution !== undefined) {
|
||||
await notifyRunDispatch(tx);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyExternalAgentSettlement(tx);
|
||||
await notifyInstructionCommit(tx);
|
||||
await notifyDirectoryCleanup(tx);
|
||||
await notifySkillVersionRetentionReconstruction(tx);
|
||||
await notifyInstructionCollection(tx);
|
||||
await notifyCancelledWorkspacePreservation(tx);
|
||||
await notifyExternalConversationSettlement(tx);
|
||||
await notifyNativeRecoveryNotices(tx);
|
||||
await notifyNativeRunProjection(tx);
|
||||
await notifyNativeEnvironmentSettlement(tx);
|
||||
await notifyNativeResultCommit(tx);
|
||||
await notifyNativeWorkspaceFinalization(tx);
|
||||
}
|
||||
if (patch.enableChatConnectors !== undefined) await notifyVoiceChange(tx);
|
||||
await notifyToolConnectionHealth(tx, "*");
|
||||
return tx
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
experimental: { ...nextExperimental },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
});
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
listCompanyIds: async (): Promise<string[]> =>
|
||||
db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.then((rows) => rows.map((row) => row.id)),
|
||||
};
|
||||
}
|
||||
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import type { PgInsertValue } from "drizzle-orm/pg-core";
|
||||
import { issueComments, type Db } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
@@ -6,6 +7,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 notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, `company:${input.companyId}`);
|
||||
return tx.insert(issueComments).values(input).returning();
|
||||
});
|
||||
|
||||
@@ -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,13 +1 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeReviewResponse } from "./native-review-response-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
|
||||
export async function notifyIssueReviewState(tx: object, scope: { companyId: string; issueId?: string; runId?: string; agentId?: string }) {
|
||||
await notifyRunDispatch(tx, scope);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyExternalConversationSettlement(tx, scope);
|
||||
await notifyNativeReviewResponse(tx, scope);
|
||||
await notifyNativeStatusReassessment(tx, scope);
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
@@ -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 { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
@@ -1011,6 +1012,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
-14100
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -2069,6 +2070,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,8 +1,10 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type NativeStatusReassessmentScope = { companyId: string; issueId?: string; runId?: string; agentId?: string };
|
||||
|
||||
export function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
|
||||
export async function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
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,94 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:*");
|
||||
}
|
||||
@@ -128,10 +128,8 @@ export function isWithinStatusCardActiveHours(policy: StatusCardRefreshPolicy, n
|
||||
}
|
||||
|
||||
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 +170,37 @@ 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;
|
||||
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);
|
||||
}
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(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))) return new Date(at);
|
||||
}
|
||||
// Equal start/end describes an empty window; retain changes, awaiting config.
|
||||
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 {
|
||||
agents,
|
||||
companies,
|
||||
costEvents,
|
||||
documentRevisions,
|
||||
documents,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
extractIssueMentions,
|
||||
filterStatusCardChanges,
|
||||
nextStatusCardEvaluationAt,
|
||||
nextStatusCardPolicyEligibility,
|
||||
STATUS_CARD_MAX_MENTIONED_ISSUES,
|
||||
statusCardChangesHash,
|
||||
statusCardFingerprintHash,
|
||||
@@ -151,9 +154,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 | undefined, 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 +249,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,7 +301,7 @@ export function statusCardService(
|
||||
...(input.refreshPolicy !== undefined
|
||||
? {
|
||||
refreshPolicy: input.refreshPolicy,
|
||||
nextEvalAt: card.archivedAt ? null : nextStatusCardEvaluationAt(input.refreshPolicy, now),
|
||||
nextEvalAt: card.archivedAt ? null : input.refreshPolicy.mode === "reactive" && card.queries.length > 0 ? now : nextStatusCardEvaluationAt(input.refreshPolicy, now),
|
||||
}
|
||||
: {}),
|
||||
...(archiveChanged && input.archived
|
||||
@@ -309,21 +318,21 @@ export function statusCardService(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
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);
|
||||
return mutate(undefined, tx => tx.delete(statusCards).where(eq(statusCards.id, id)).returning().then((rows) => rows[0] ?? null));
|
||||
}
|
||||
|
||||
async function listUpdates(cardId: string) {
|
||||
@@ -360,7 +369,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 +400,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 +418,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 +473,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");
|
||||
@@ -580,12 +589,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) {
|
||||
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,
|
||||
issueReadCondition: options.issueReadCondition, onNextMembershipChange,
|
||||
});
|
||||
for (const result of response.results) {
|
||||
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
|
||||
@@ -622,6 +631,12 @@ export function statusCardService(
|
||||
return snapshot.map((issue) => ({ ...issue, latestHumanCommentAt: commentByIssueId.get(issue.id) ?? null }));
|
||||
}
|
||||
|
||||
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 requestRefresh(cardId: string, input: {
|
||||
full?: boolean;
|
||||
trigger?: "manual" | "interval" | "reactive" | "restore";
|
||||
@@ -642,21 +657,22 @@ export function statusCardService(
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
const snapshot = await executeQueries(card);
|
||||
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();
|
||||
}).where(eq(statusCards.id, card.id)).returning());
|
||||
return { card: next!, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
}
|
||||
|
||||
@@ -678,13 +694,17 @@ 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();
|
||||
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(eq(statusCards.id, card.id)).returning());
|
||||
return { card: next!, generatingIssue: null, alreadyGenerating: false, enqueued: false };
|
||||
}
|
||||
|
||||
@@ -707,7 +727,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",
|
||||
@@ -720,15 +740,15 @@ export function statusCardService(
|
||||
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,
|
||||
@@ -736,17 +756,17 @@ export function statusCardService(
|
||||
state: "active",
|
||||
nextEvalAt,
|
||||
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), 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" });
|
||||
await issuesSvc().update(generationIssue!.id, { status: "cancelled" });
|
||||
}
|
||||
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" });
|
||||
await issuesSvc().update(generationIssue!.id, { status: "cancelled" });
|
||||
}
|
||||
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 +785,50 @@ 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 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 restores every disarmed reactive scope. A concurrent committed
|
||||
// source hint can additionally preempt its company's saved future deadline.
|
||||
const reactiveScope = or(reconstruct ? isNull(statusCards.nextEvalAt) : undefined, 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, 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 db.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 })
|
||||
.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 +838,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 +858,9 @@ 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;
|
||||
const watchedSnapshot = await executeQueries({ ...current, mentionedIssueIds }, {}, at => { if (at && (!membershipAt || at < membershipAt)) membershipAt = at; });
|
||||
const watchedNow = buildStatusCardFingerprint(watchedSnapshot);
|
||||
let snapshot: StatusCardFingerprint;
|
||||
if (payloadFingerprint) {
|
||||
// Keep the generation-time fingerprint as the change baseline: issues
|
||||
@@ -889,7 +931,9 @@ export function statusCardService(
|
||||
pendingChangeCount: 0,
|
||||
pendingChangeHash: null,
|
||||
lastChangeAt: null,
|
||||
nextEvalAt: nextStatusCardEvaluationAt(current.refreshPolicy, now),
|
||||
nextEvalAt: current.refreshPolicy.mode === "reactive" &&
|
||||
filterStatusCardChanges(diffStatusCardFingerprint(snapshot, watchedNow), current.refreshPolicy).length > 0
|
||||
? 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");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -1398,10 +1399,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