@arcforgelabs/flow 2026.10.32 → 2026.10.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +3 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +56 -24
  5. package/server/app.js +4 -0
  6. package/server/core/invoicing-rules.js +50 -4
  7. package/server/core/job-board.js +15 -4
  8. package/server/core/ledger.js +242 -6
  9. package/server/core/pipeline.js +29 -21
  10. package/server/core/read-before-act.js +96 -0
  11. package/server/core/reconcile.js +350 -0
  12. package/server/core/sending.js +44 -6
  13. package/server/db/migrations/0017_sync_basis.sql +1 -0
  14. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  15. package/server/db/migrations/meta/_journal.json +7 -0
  16. package/server/db/schema.js +3 -0
  17. package/server/index.js +5 -0
  18. package/server/integrations/port.js +3 -0
  19. package/server/integrations/seazona/act.js +48 -0
  20. package/server/integrations/seazona/feed.js +70 -50
  21. package/server/integrations/seazona/governor.js +28 -2
  22. package/server/integrations/seazona/index.js +60 -3
  23. package/server/integrations/seazona/lanes.js +81 -8
  24. package/server/integrations/seazona/ledger.js +14 -9
  25. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  26. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  27. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  28. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  29. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  30. package/server/integrations/seazona/reconcile.js +40 -0
  31. package/server/integrations/seazona/scheduler.js +32 -4
  32. package/server/integrations/seazona/schema.js +21 -0
  33. package/server/integrations/seazona/settings.js +12 -0
  34. package/server/integrations/seazona/store.js +24 -2
  35. package/server/integrations/seazona/sweep.js +574 -0
  36. package/server/integrations/seazona/tables.js +1 -0
  37. package/server/integrations/xero/index.js +4 -0
  38. package/server/integrations/xero/webhooks.js +35 -0
  39. package/server/ui/flow.css +9 -0
  40. package/server/ui/invoicing.js +101 -17
  41. package/server/ui/job-board.js +2 -1
  42. package/server/ui/jobs.js +19 -1
@@ -36,6 +36,20 @@
36
36
  "when": 1792453111948,
37
37
  "tag": "0004_history_import",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1792458014317,
44
+ "tag": "0005_record_reread",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1792459854631,
51
+ "tag": "0006_sweep_findings",
52
+ "breakpoints": true
39
53
  }
40
54
  ]
41
55
  }
@@ -0,0 +1,40 @@
1
+ import { readBillableWork } from './feed.js';
2
+
3
+ // The billable jobs in Flow's copy of Seazona, by day, behind the port's
4
+ // `billableJobs` (the daily reconcile, server/core/reconcile.js). It walks the
5
+ // billing feed from its start, so a job counts here exactly when the feed
6
+ // gives it to the ledger, on the day the ledger files it (its `jobDate`). It
7
+ // reads Flow's copy only; it never calls Seazona.
8
+ //
9
+ // A feed key only moves forward, so an order read again while the walk runs
10
+ // is counted once, and none is missed.
11
+ export async function readBillableJobs(db, { from, to, pageSize = 1000 } = {}) {
12
+ const jobs = new Map();
13
+ let cursor = null;
14
+ for (;;) {
15
+ const page = await readBillableWork(db, { cursor, limit: pageSize });
16
+ for (const { ledgerJob: job } of page.items) {
17
+ // A job with no lines never reaches the ledger (pipeline.js, ingestWork).
18
+ if (!job.lines.length) {
19
+ jobs.delete(job.sourceJobRef);
20
+ continue;
21
+ }
22
+ if (!job.jobDate || (from && job.jobDate < from) || (to && job.jobDate > to)) {
23
+ jobs.delete(job.sourceJobRef);
24
+ continue;
25
+ }
26
+ // Unknown when a line has no amount: its price item fills it.
27
+ const priced = job.lines.every((l) => Number.isInteger(l.unitAmountCents));
28
+ jobs.set(job.sourceJobRef, {
29
+ ref: job.sourceJobRef,
30
+ day: job.jobDate,
31
+ subtotalCents: priced ? job.lines.reduce((sum, l) => sum + l.unitAmountCents * l.quantity, 0) : null,
32
+ });
33
+ }
34
+ // A job that stopped being billable, or was deleted, is not counted.
35
+ for (const { sourceId } of page.withdrawn) jobs.delete(sourceId);
36
+ if (!page.more) break;
37
+ cursor = page.cursor;
38
+ }
39
+ return { jobs: [...jobs.values()] };
40
+ }
@@ -6,6 +6,11 @@ import { readLaneStates } from './store.js';
6
6
  // the next lane is due, between 15 seconds and 30 minutes. One run at a time.
7
7
  // A stopped lane (issue #101) is not due until a person or a release resets it.
8
8
  //
9
+ // Issue #143: a run that has not ended after `stuckMs` is stuck (for example a
10
+ // database call that never answers). It is reported once, /health says so,
11
+ // and the scheduler goes on with the next run without it. Lane claims
12
+ // (store.js, claimLane) keep two runs off one lane.
13
+ //
9
14
  // Issue #101: each held failure is reported once to the error services
10
15
  // (log.error, server/core/monitoring.js): when a lane starts failing or its
11
16
  // failure code changes, and when records are quarantined. A repeat of the
@@ -23,12 +28,15 @@ export function createSeazonaScheduler({
23
28
  log = console,
24
29
  timers = { setTimeout, clearTimeout },
25
30
  firstDelayMs = 5_000,
31
+ stuckMs = settings?.sync?.stuckLaneMs ?? 20 * 60_000,
26
32
  } = {}) {
27
33
  let timer = null;
28
34
  let running = null;
29
35
  let stopped = false;
30
36
  let nextRunAt = null;
31
37
  let last = null;
38
+ // When the run that is stuck now started, or null.
39
+ let stuckSince = null;
32
40
  // lane -> the failure code last reported, while that lane keeps failing.
33
41
  const reported = new Map();
34
42
  const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
@@ -45,6 +53,8 @@ export function createSeazonaScheduler({
45
53
  reported.delete(lane);
46
54
  const count = newlyQuarantined(outcome);
47
55
  if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
56
+ // Issue #126: what the sweep found, once per run that found it.
57
+ for (const line of outcome.reports ?? []) log.error?.(`[seazona] ${lane}: ${line}`);
48
58
  continue;
49
59
  }
50
60
  if (reported.get(lane) === outcome.code) {
@@ -78,8 +88,9 @@ export function createSeazonaScheduler({
78
88
  async function tick() {
79
89
  timer = null;
80
90
  if (stopped) return;
81
- running = (async () => {
82
- const startedAt = new Date(Number(now())).toISOString();
91
+ const startedAt = new Date(Number(now())).toISOString();
92
+ const turn = { abandoned: false };
93
+ const thisRun = (async () => {
83
94
  try {
84
95
  const result = await run();
85
96
  const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
@@ -90,7 +101,24 @@ export function createSeazonaScheduler({
90
101
  log.error?.(`[seazona] sync run failed: ${last.error}`);
91
102
  }
92
103
  })();
93
- await running;
104
+ running = thisRun;
105
+ const watchdog = timers.setTimeout(() => {
106
+ if (stopped || turn.abandoned) return;
107
+ turn.abandoned = true;
108
+ stuckSince = startedAt;
109
+ running = null;
110
+ log.error?.(`[seazona] a sync run has not ended after ${Math.round(stuckMs / 60_000)} minutes; the next run starts without it`);
111
+ schedule(MIN_DELAY_MS);
112
+ }, stuckMs);
113
+ watchdog?.unref?.();
114
+ await thisRun;
115
+ timers.clearTimeout(watchdog);
116
+ // A stuck run that ends late leaves the schedule to the run after it.
117
+ if (turn.abandoned) {
118
+ if (stuckSince === startedAt) stuckSince = null;
119
+ return;
120
+ }
121
+ stuckSince = null;
94
122
  running = null;
95
123
  schedule(await nextDelayMs().catch(() => MIN_DELAY_MS));
96
124
  }
@@ -108,7 +136,7 @@ export function createSeazonaScheduler({
108
136
  await running;
109
137
  },
110
138
  status() {
111
- return { running: Boolean(running), nextRunAt, lastRun: last };
139
+ return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
112
140
  },
113
141
  };
114
142
  }
@@ -170,9 +170,12 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
170
170
  // Issue #101: each failed detail read of an invoice or a client, per record.
171
171
  // Orders keep their own table (issue #12). After a few failures in a row the
172
172
  // record is quarantined: the lane skips it and its cursor moves on.
173
+ // Issue #142: `detail_id` is the id its detail path needs (an invoice's key
174
+ // is its number), so the lane can read it again when its wait ends.
173
175
  export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
174
176
  resource: text('resource').notNull(),
175
177
  recordKey: text('record_key').notNull(),
178
+ detailId: text('detail_id'),
176
179
  failureCount: integer('failure_count').notNull(),
177
180
  lastAttemptAt: at('last_attempt_at').notNull(),
178
181
  lastHttpStatus: integer('last_http_status'),
@@ -271,3 +274,21 @@ export const seazonaHistoryJobs = pgTable('seazona_history_jobs', {
271
274
  index('seazona_history_jobs_invoice').on(t.invoiceNumber),
272
275
  index('seazona_history_jobs_changed').on(t.lastChangedAt),
273
276
  ]);
277
+
278
+ // Issue #126: what the sweep found that the cursor lanes missed, one row per
279
+ // record and kind. `changed` and `created`: an edit or a new record the lanes
280
+ // passed by. `deleted`: an order Seazona answered 404 for. `missing`: an
281
+ // invoice or client that Seazona no longer lists; the row stays, and the
282
+ // mark clears when Seazona lists the record again.
283
+ export const seazonaSweepFindings = pgTable('seazona_sweep_findings', {
284
+ resource: text('resource').notNull(),
285
+ recordKey: text('record_key').notNull(),
286
+ kind: text('kind').notNull(),
287
+ firstFoundAt: at('first_found_at').notNull(),
288
+ lastFoundAt: at('last_found_at').notNull(),
289
+ // Status codes and statuses only. Never patient data.
290
+ detail: jsonb('detail'),
291
+ }, (t) => [
292
+ primaryKey({ columns: [t.resource, t.recordKey, t.kind] }),
293
+ index('seazona_sweep_findings_found').on(t.lastFoundAt),
294
+ ]);
@@ -102,7 +102,16 @@ export function resolveSeazonaSettings(env = process.env) {
102
102
  // The history backfill (docs/SEAZONA.md, "History import"). It calls
103
103
  // Seazona only while a person has started one.
104
104
  history: { cadenceMs: ms('SEAZONA_HISTORY_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * DAY_MS },
105
+ // Issue #126: the slow re-check sweep (sweep.js). A few calls a run,
106
+ // inside a small daily share.
107
+ sweep: { cadenceMs: ms('SEAZONA_SWEEP_CADENCE_MS', 15 * MINUTE, MINUTE, DAY_MS), freshnessMs: 2 * DAY_MS },
105
108
  },
109
+ // Issue #126: calls the sweep makes per run and per UTC day, at most. 0 a
110
+ // day turns the sweep off. Each kind of record (old orders, clients,
111
+ // invoices) is checked again at most once a cycle.
112
+ sweepCallsPerRun: clampInt(env.SEAZONA_SWEEP_CALLS_PER_RUN, 10, 1, 25),
113
+ sweepCallsPerDay: clampInt(env.SEAZONA_SWEEP_CALLS_PER_DAY, 100, 0, 1000),
114
+ sweepCycleMs: clampInt(env.SEAZONA_SWEEP_CYCLE_DAYS, 7, 1, 90) * DAY_MS,
106
115
  // Calls the history backfill makes per run, at most: a small share of
107
116
  // the rate limit, so the live lanes always have room.
108
117
  historyCallsPerRun: clampInt(env.SEAZONA_HISTORY_CALLS_PER_RUN, 10, 1, 25),
@@ -128,6 +137,9 @@ export function resolveSeazonaSettings(env = process.env) {
128
137
  // Issue #12: after this many failed reads in a row an order waits this long.
129
138
  orderQuarantineFailures: clampInt(env.SEAZONA_ORDER_QUARANTINE_FAILURES, 3, 1, 20),
130
139
  orderQuarantineMs: ms('SEAZONA_ORDER_QUARANTINE_MS', DAY_MS, 5 * MINUTE, 30 * DAY_MS),
140
+ // Issue #142: the invoice and client lanes read this many quarantined
141
+ // records again per run, once their wait has ended.
142
+ recordRereadsPerRun: clampInt(env.SEAZONA_RECORD_REREADS_PER_RUN, 5, 0, 50),
131
143
  // A failed lane waits 1 minute, doubling to at most 6 hours.
132
144
  retryBaseMs: MINUTE,
133
145
  retryMaxMs: 6 * 60 * MINUTE,
@@ -1,4 +1,4 @@
1
- import { and, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
1
+ import { and, asc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from 'drizzle-orm';
2
2
  import { hashOf } from './map.js';
3
3
  import {
4
4
  seazonaClients,
@@ -215,11 +215,12 @@ export async function clearRecordFailures(db, resource, keys) {
215
215
 
216
216
  // Records one failed read, as recordOrderFailure does for orders. Answers
217
217
  // { failureCount, quarantinedUntil }.
218
- export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
218
+ export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs, detailId = null }) {
219
219
  const until = new Date(at.getTime() + quarantineMs);
220
220
  const [row] = await db.insert(seazonaRecordReadFailures).values({
221
221
  resource,
222
222
  recordKey: key,
223
+ detailId,
223
224
  failureCount: 1,
224
225
  lastAttemptAt: at,
225
226
  lastHttpStatus: failure.httpStatus,
@@ -229,6 +230,7 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
229
230
  target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
230
231
  set: {
231
232
  failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
233
+ detailId: sql`coalesce(${detailId}::text, ${seazonaRecordReadFailures.detailId})`,
232
234
  lastAttemptAt: at,
233
235
  lastHttpStatus: failure.httpStatus,
234
236
  lastFailureKind: failure.kind,
@@ -239,6 +241,26 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
239
241
  return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
240
242
  }
241
243
 
244
+ // Issue #142: quarantined records whose wait has ended, oldest wait first,
245
+ // that the lane can read again by id.
246
+ export async function readRecordsDueForReread(db, resource, { at, limit }) {
247
+ return db.select().from(seazonaRecordReadFailures).where(and(
248
+ eq(seazonaRecordReadFailures.resource, resource),
249
+ isNotNull(seazonaRecordReadFailures.detailId),
250
+ lte(seazonaRecordReadFailures.quarantinedUntil, at),
251
+ )).orderBy(asc(seazonaRecordReadFailures.quarantinedUntil)).limit(limit);
252
+ }
253
+
254
+ // Per resource: quarantined records whose wait has ended, so they wait for a
255
+ // re-read.
256
+ export async function readRecordRereadCounts(db, { at }) {
257
+ const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
258
+ .from(seazonaRecordReadFailures)
259
+ .where(lte(seazonaRecordReadFailures.quarantinedUntil, at))
260
+ .groupBy(seazonaRecordReadFailures.resource);
261
+ return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
262
+ }
263
+
242
264
  export async function readQuarantinedRecordCounts(db, { at }) {
243
265
  const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
244
266
  .from(seazonaRecordReadFailures)