@arcforgelabs/flow 2026.10.29 → 2026.10.30

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.
@@ -15,6 +15,13 @@
15
15
  "when": 1792330268730,
16
16
  "tag": "0001_mirror",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1792448378477,
23
+ "tag": "0002_call_safety",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
@@ -4,12 +4,20 @@ import { readLaneStates } from './store.js';
4
4
  // The background sync, for live mode only (issue #10: index.js never creates
5
5
  // it in sandbox or replay). It runs the lanes that are due, then sleeps until
6
6
  // the next lane is due, between 15 seconds and 30 minutes. One run at a time.
7
+ // A stopped lane (issue #101) is not due until a person or a release resets it.
8
+ //
9
+ // Issue #101: each held failure is reported once to the error services
10
+ // (log.error, server/core/monitoring.js): when a lane starts failing or its
11
+ // failure code changes, and when records are quarantined. A repeat of the
12
+ // same failure is a warning in the host log only. The report text is stable,
13
+ // so the error service groups repeats.
7
14
 
8
15
  const MIN_DELAY_MS = 15_000;
9
16
  const MAX_DELAY_MS = 30 * 60_000;
10
17
 
11
18
  export function createSeazonaScheduler({
12
19
  db,
20
+ settings = null,
13
21
  run,
14
22
  now = Date.now,
15
23
  log = console,
@@ -21,11 +29,39 @@ export function createSeazonaScheduler({
21
29
  let stopped = false;
22
30
  let nextRunAt = null;
23
31
  let last = null;
32
+ // lane -> the failure code last reported, while that lane keeps failing.
33
+ const reported = new Map();
34
+ const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
35
+
36
+ function newlyQuarantined(outcome) {
37
+ if (Number.isInteger(outcome.newlyQuarantined)) return outcome.newlyQuarantined;
38
+ const failures = outcome.failureDetails?.failures ?? [];
39
+ return failures.filter((f) => f.quarantinedUntil && f.failureCount === quarantineAt).length;
40
+ }
41
+
42
+ function report(results) {
43
+ for (const [lane, outcome] of Object.entries(results)) {
44
+ if (outcome.ok) {
45
+ reported.delete(lane);
46
+ const count = newlyQuarantined(outcome);
47
+ if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
48
+ continue;
49
+ }
50
+ if (reported.get(lane) === outcome.code) {
51
+ log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
52
+ continue;
53
+ }
54
+ reported.set(lane, outcome.code);
55
+ log.error?.(`[seazona] ${lane} lane ${outcome.status === 'stopped' ? 'stopped' : 'failed'}: ${outcome.code}`);
56
+ log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
57
+ }
58
+ }
24
59
 
25
60
  async function nextDelayMs() {
26
61
  const states = await readLaneStates(db);
27
62
  let soonest = Infinity;
28
63
  for (const lane of LANES) {
64
+ if (states.get(lane)?.status === 'stopped') continue;
29
65
  const at = states.get(lane)?.nextRunAt?.getTime();
30
66
  soonest = Math.min(soonest, Number.isFinite(at) ? at - Number(now()) : 0);
31
67
  }
@@ -48,7 +84,7 @@ export function createSeazonaScheduler({
48
84
  const result = await run();
49
85
  const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
50
86
  last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
51
- for (const [lane, outcome] of failed) log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
87
+ report(result.results);
52
88
  } catch (error) {
53
89
  last = { at: startedAt, ok: false, error: error?.message || String(error) };
54
90
  log.error?.(`[seazona] sync run failed: ${last.error}`);
@@ -10,10 +10,17 @@ const at = (name) => timestamp(name, { withTimezone: true });
10
10
 
11
11
  // One row per API client: when the next request may go, and any cooldown a
12
12
  // 429 asked for. Every process that shares the database shares this row.
13
+ // Issue #101: the circuit breaker (open until, and how many times in a row it
14
+ // opened) and a stop that waits for a person (`halted_reason`, for example
15
+ // `auth` after a 401).
13
16
  export const seazonaApiGovernor = pgTable('seazona_api_governor', {
14
17
  clientKey: text('client_key').primaryKey(),
15
18
  nextEligibleAt: at('next_eligible_at').notNull().default(sql`'epoch'`),
16
19
  cooldownUntil: at('cooldown_until').notNull().default(sql`'epoch'`),
20
+ circuitOpenUntil: at('circuit_open_until').notNull().default(sql`'epoch'`),
21
+ circuitOpens: integer('circuit_opens').notNull().default(0),
22
+ haltedReason: text('halted_reason'),
23
+ haltedAt: at('halted_at'),
17
24
  updatedAt: at('updated_at').notNull().defaultNow(),
18
25
  });
19
26
 
@@ -155,3 +162,18 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
155
162
  lastError: text('last_error'),
156
163
  quarantinedUntil: at('quarantined_until'),
157
164
  });
165
+
166
+ // Issue #101: each failed detail read of an invoice or a client, per record.
167
+ // Orders keep their own table (issue #12). After a few failures in a row the
168
+ // record is quarantined: the lane skips it and its cursor moves on.
169
+ export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
170
+ resource: text('resource').notNull(),
171
+ recordKey: text('record_key').notNull(),
172
+ failureCount: integer('failure_count').notNull(),
173
+ lastAttemptAt: at('last_attempt_at').notNull(),
174
+ lastHttpStatus: integer('last_http_status'),
175
+ lastFailureKind: text('last_failure_kind'),
176
+ quarantinedUntil: at('quarantined_until'),
177
+ }, (t) => [
178
+ primaryKey({ columns: [t.resource, t.recordKey] }),
179
+ ]);
@@ -75,6 +75,16 @@ export function resolveSeazonaSettings(env = process.env) {
75
75
  maxWaitMs: clampInt(env.SEAZONA_API_MAX_WAIT_MS, 120_000, 1_000, 15 * 60_000),
76
76
  // Long enough to answer a vendor's question about last month.
77
77
  telemetryRetentionMs: clampInt(env.SEAZONA_API_TELEMETRY_RETENTION_DAYS, 35, 1, 90) * DAY_MS,
78
+ // Issue #101: a vendor can cut off API access after many failed calls,
79
+ // even inside the rate limit. This many failed calls inside the window
80
+ // open the circuit: no call goes out for the pause, which doubles each
81
+ // time the probe after it fails, to at most 6 hours.
82
+ circuitFailures: clampInt(env.SEAZONA_CIRCUIT_FAILURES, 5, 1, 100),
83
+ circuitWindowMs: clampInt(env.SEAZONA_CIRCUIT_WINDOW_MS, 10 * 60_000, 60_000, DAY_MS),
84
+ circuitPauseMs: clampInt(env.SEAZONA_CIRCUIT_PAUSE_MS, 5 * 60_000, 60_000, 6 * 60 * 60_000),
85
+ // Failed calls allowed in one UTC day. When they are spent, calls stop
86
+ // until the next day.
87
+ dailyFailureBudget: clampInt(env.SEAZONA_DAILY_FAILURE_BUDGET, 50, 1, 10_000),
78
88
  };
79
89
 
80
90
  const MINUTE = 60_000;
@@ -6,6 +6,7 @@ import {
6
6
  seazonaLaneState,
7
7
  seazonaOrderReadFailures,
8
8
  seazonaOrders,
9
+ seazonaRecordReadFailures,
9
10
  seazonaReference,
10
11
  } from './schema.js';
11
12
 
@@ -192,6 +193,60 @@ export async function readQuarantinedOrders(db, { at, limit = 25 }) {
192
193
  }));
193
194
  }
194
195
 
196
+ // Issue #101: failed detail reads of invoices and clients, per record.
197
+ // Answers Map(key -> row) for the keys that are quarantined at `at`.
198
+ export async function readQuarantinedRecords(db, resource, keys, { at }) {
199
+ if (!keys.length) return new Map();
200
+ const rows = await db.select().from(seazonaRecordReadFailures).where(and(
201
+ eq(seazonaRecordReadFailures.resource, resource),
202
+ inArray(seazonaRecordReadFailures.recordKey, keys),
203
+ gt(seazonaRecordReadFailures.quarantinedUntil, at),
204
+ ));
205
+ return new Map(rows.map((row) => [row.recordKey, row]));
206
+ }
207
+
208
+ export async function clearRecordFailures(db, resource, keys) {
209
+ if (!keys.length) return;
210
+ await db.delete(seazonaRecordReadFailures).where(and(
211
+ eq(seazonaRecordReadFailures.resource, resource),
212
+ inArray(seazonaRecordReadFailures.recordKey, keys),
213
+ ));
214
+ }
215
+
216
+ // Records one failed read, as recordOrderFailure does for orders. Answers
217
+ // { failureCount, quarantinedUntil }.
218
+ export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
219
+ const until = new Date(at.getTime() + quarantineMs);
220
+ const [row] = await db.insert(seazonaRecordReadFailures).values({
221
+ resource,
222
+ recordKey: key,
223
+ failureCount: 1,
224
+ lastAttemptAt: at,
225
+ lastHttpStatus: failure.httpStatus,
226
+ lastFailureKind: failure.kind,
227
+ quarantinedUntil: quarantineFailures <= 1 ? until : null,
228
+ }).onConflictDoUpdate({
229
+ target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
230
+ set: {
231
+ failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
232
+ lastAttemptAt: at,
233
+ lastHttpStatus: failure.httpStatus,
234
+ lastFailureKind: failure.kind,
235
+ quarantinedUntil: sql`case when ${seazonaRecordReadFailures.failureCount} + 1 >= ${quarantineFailures}
236
+ then ${until}::timestamptz else null end`,
237
+ },
238
+ }).returning();
239
+ return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
240
+ }
241
+
242
+ export async function readQuarantinedRecordCounts(db, { at }) {
243
+ const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
244
+ .from(seazonaRecordReadFailures)
245
+ .where(gt(seazonaRecordReadFailures.quarantinedUntil, at))
246
+ .groupBy(seazonaRecordReadFailures.resource);
247
+ return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
248
+ }
249
+
195
250
  // Lane state.
196
251
 
197
252
  export async function readLaneStates(db) {
@@ -214,6 +269,25 @@ export async function claimLane(db, lane, { at, stuckMs }) {
214
269
  return claimed.length > 0;
215
270
  }
216
271
 
272
+ // Issue #101: a lane stopped for a person or a release runs again. Answers
273
+ // the lanes it reset.
274
+ export async function resetStoppedLanes(db, { at }) {
275
+ const rows = await db.update(seazonaLaneState)
276
+ .set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
277
+ .where(eq(seazonaLaneState.status, 'stopped'))
278
+ .returning({ lane: seazonaLaneState.lane });
279
+ return rows.map((row) => row.lane);
280
+ }
281
+
282
+ // A person acted: every lane that failed or stopped runs at the next tick.
283
+ export async function resetFailedLanes(db, { at }) {
284
+ const rows = await db.update(seazonaLaneState)
285
+ .set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
286
+ .where(inArray(seazonaLaneState.status, ['stopped', 'failed', 'held']))
287
+ .returning({ lane: seazonaLaneState.lane });
288
+ return rows.map((row) => row.lane);
289
+ }
290
+
217
291
  export async function finishLane(db, lane, patch) {
218
292
  await db.update(seazonaLaneState).set({ ...patch, updatedAt: patch.lastAttemptAt ?? new Date() }).where(eq(seazonaLaneState.lane, lane));
219
293
  }
@@ -13,5 +13,6 @@ export const seazonaSchema = Object.freeze({
13
13
  'seazona_reference',
14
14
  'seazona_lane_state',
15
15
  'seazona_order_read_failures',
16
+ 'seazona_record_read_failures',
16
17
  ]),
17
18
  });
@@ -109,6 +109,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
109
109
  if (ran) {
110
110
  busy = true;
111
111
  const read = (ran.job.result?.results ?? []).map((r) => `${r.resource} ${r.read}`).join(', ');
112
+ if (ran.outcome === 'dead') log.error?.('[xero] the history import gave up after its last attempt');
112
113
  log.info?.(`[xero] import ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
113
114
  }
114
115
  } catch (error) {
@@ -16,6 +16,16 @@ function loadScript({ src, integrity }) {
16
16
  });
17
17
  }
18
18
 
19
+ // "Notify me": the form's email field (Sentry's own words) starts empty and
20
+ // is optional. A person who types an address there asks to hear back; Flow
21
+ // tags the report notify=yes, so the support desk sees it on the task.
22
+ // Sentry's form has no tick box of its own; this keeps to its stock options.
23
+ export function tagNotify(event) {
24
+ if (event?.type !== 'feedback') return;
25
+ const email = String(event.contexts?.feedback?.contact_email ?? '').trim();
26
+ event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
27
+ }
28
+
19
29
  /** Resolves to a function that opens the "Report a problem" form, or null. */
20
30
  export async function startMonitoring({ flow, person }) {
21
31
  const config = await flow('/api/monitoring');
@@ -30,6 +40,9 @@ export async function startMonitoring({ flow, person }) {
30
40
  showBranding: false,
31
41
  // Flow already knows who is signed in (setUser below).
32
42
  showName: false,
43
+ // The email field is the opt-in (tagNotify), so it is not filled from
44
+ // the signed-in person: a key setUser never sets.
45
+ useSentryUser: { name: 'username', email: 'notify_email' },
33
46
  enableScreenshot: true,
34
47
  triggerLabel: 'Report a problem',
35
48
  formTitle: 'Report a problem',
@@ -45,6 +58,7 @@ export async function startMonitoring({ flow, person }) {
45
58
  sendDefaultPii: false,
46
59
  integrations,
47
60
  });
61
+ Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
48
62
  // Who reported or met the error, so the lab can follow up.
49
63
  Sentry.setUser({ id: person.id, email: person.email });
50
64
  const feedback = config.feedback ? Sentry.getFeedback() : null;