@arcforgelabs/flow 2026.10.39 → 2026.10.40

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.39",
3
+ "version": "2026.10.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.39",
9
+ "version": "2026.10.40",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.39",
3
+ "version": "2026.10.40",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,10 @@ import { sql } from 'drizzle-orm';
9
9
  // - a refused credential halts every call until a person acts (saves the
10
10
  // settings or connects again, which calls resume());
11
11
  // - a 401 or 403 on one resource, when the adapter's auth failures stop a
12
- // resource only, stops that resource until a person acts;
12
+ // resource only, stops that resource; after `resourceRetryMs` one probe
13
+ // call may go, and a refused probe stops it again for twice as long, to
14
+ // at most `maxResourceRetryMs`. A good call, or a person connecting
15
+ // again, starts it again;
13
16
  // - `circuitFailures` failed calls inside `circuitWindowMs` open the
14
17
  // circuit: no call for `circuitPauseMs`, then a probe; a failed probe opens
15
18
  // it again for twice as long, to at most `maxCircuitPauseMs`, and a
@@ -34,6 +37,8 @@ export const CALL_GUARD_POLICY = Object.freeze({
34
37
  // 'all': a 401 or 403 halts every call. 'resource': it stops only the
35
38
  // resource it came from; halt() still stops everything.
36
39
  authStops: 'all',
40
+ resourceRetryMs: 6 * 60 * 60_000,
41
+ maxResourceRetryMs: 24 * 60 * 60_000,
37
42
  telemetryRetentionMs: 35 * 24 * 60 * 60_000,
38
43
  });
39
44
 
@@ -68,12 +73,31 @@ export function circuitPauseMs(opens, policy = CALL_GUARD_POLICY) {
68
73
  return Math.min(policy.maxCircuitPauseMs, policy.circuitPauseMs * 2 ** Math.min(Math.max(0, opens), 10));
69
74
  }
70
75
 
76
+ // The wait before the next probe of a stopped resource, after `probes`
77
+ // refused probes.
78
+ export function resourceRetryMs(probes, policy = CALL_GUARD_POLICY) {
79
+ return Math.min(policy.maxResourceRetryMs, policy.resourceRetryMs * 2 ** Math.min(Math.max(0, probes), 10));
80
+ }
81
+
82
+ // When a stopped resource may be probed again (ms). An entry written before
83
+ // probes existed has no `retryAt`: it is due `resourceRetryMs` after `at`.
84
+ export function resourceRetryAt(entry, policy = CALL_GUARD_POLICY) {
85
+ const due = entry?.retryAt ? new Date(entry.retryAt).getTime() : NaN;
86
+ if (Number.isFinite(due)) return due;
87
+ const at = entry?.at ? new Date(entry.at).getTime() : NaN;
88
+ return Number.isFinite(at) ? at + resourceRetryMs(0, policy) : Infinity;
89
+ }
90
+
71
91
  // Why no call may go now, or null. `state` is the guard row
72
92
  // ({ haltedReason, circuitOpenUntil, stoppedResources }); `failedToday`
73
93
  // counts failed calls since the start of the UTC day.
74
94
  export function stoppedReason(state, { nowMs, failedToday, resource = null }, policy = CALL_GUARD_POLICY) {
75
95
  if (state?.haltedReason) return { reason: 'halted', retryAfterMs: null };
76
- if (resource && state?.stoppedResources?.[resource]) return { reason: 'resource_stopped', retryAfterMs: null, resource };
96
+ const entry = resource ? state?.stoppedResources?.[resource] : null;
97
+ if (entry) {
98
+ const due = resourceRetryAt(entry, policy);
99
+ if (due > nowMs) return { reason: 'resource_stopped', retryAfterMs: due - nowMs, resource };
100
+ }
77
101
  const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
78
102
  if (openUntil > nowMs) return { reason: 'circuit_open', retryAfterMs: openUntil - nowMs };
79
103
  const dayStart = startOfUtcDay(nowMs);
@@ -95,7 +119,7 @@ export function circuitAfterFailure(state, { nowMs, recentFailures }, policy = C
95
119
 
96
120
  const STOP_TEXT = {
97
121
  halted: (name) => `${name} calls are stopped until a person checks the connection (it was refused)`,
98
- resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped until a person checks the connection (they were refused)`,
122
+ resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped (they were refused); Flow tries again later by itself`,
99
123
  circuit_open: (name) => `${name} calls are paused after repeated failures`,
100
124
  budget_spent: (name) => `${name} calls are stopped for today: the day's failed calls are spent`,
101
125
  };
@@ -138,12 +162,14 @@ export function guardAlerts(name, status, { fix = 'Check the connection, then co
138
162
  members: [`halted:${status.haltedAt ?? ''}`],
139
163
  });
140
164
  }
165
+ // One refused resource is a warning, not a stop: every other call still
166
+ // goes, and Flow probes the resource again by itself.
141
167
  const resources = status?.stoppedResources ?? [];
142
168
  if (resources.length) {
143
169
  alerts.push({
144
170
  id: 'resources-stopped',
145
- tone: 'danger',
146
- title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow stopped those calls. ${fix}`,
171
+ tone: 'warn',
172
+ title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Other ${name} calls still work. Flow tries again by itself. To fix it now, connect again.`,
147
173
  members: resources.map((r) => `${r.resource}:${r.at ?? ''}`),
148
174
  });
149
175
  }
@@ -233,7 +259,8 @@ export function createCallGuard({
233
259
  }
234
260
 
235
261
  // Records how one call ended, and what that does to the guard. Answers
236
- // what this call stopped ({ halted, resourceStopped, opened, budgetSpent }),
262
+ // what this call changed ({ halted, resourceStopped, resourceStillStopped,
263
+ // resourceResumed, opened, closed, budgetSpent }),
237
264
  // each of which was just reported once. `callId` names a call row the
238
265
  // adapter wrote when it admitted the call: that row is updated, not added.
239
266
  async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
@@ -256,22 +283,35 @@ export function createCallGuard({
256
283
  }
257
284
  const state = await readState(tx, key);
258
285
  if (!countsAsFailure({ statusCode: status, failureKind })) {
286
+ const result = {};
287
+ // A good probe: the resource is allowed again.
288
+ if (where && state.stoppedResources[where] && status !== null && status < 400) {
289
+ await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources - ${where}, updated_at = ${nowAt} where ${C.key} = ${key}`);
290
+ result.resourceResumed = where;
291
+ }
259
292
  if (state.circuitOpens > 0) {
260
293
  await tx.execute(sql`update ${T.state} set circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
261
- return { closed: true };
294
+ result.closed = true;
262
295
  }
263
- return {};
296
+ return result;
264
297
  }
265
298
  const result = {};
266
299
  const dayStart = startOfUtcDay(nowMs);
267
300
  if (await failuresSince(tx, key, dayStart) === policy.dailyFailureBudget) result.budgetSpent = { until: new Date(dayStart + DAY_MS).toISOString() };
268
301
  if (isAuthRefusal(status)) {
269
302
  if (policy.authStops === 'resource' && where) {
270
- if (!state.stoppedResources[where]) {
271
- await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: { statusCode: status, at: nowAt.toISOString() } })}::jsonb,
272
- updated_at = ${nowAt} where ${C.key} = ${key}`);
273
- result.resourceStopped = where;
274
- }
303
+ const before = state.stoppedResources[where];
304
+ const probes = before ? (Number(before.probes) || 0) + 1 : 0;
305
+ const entry = {
306
+ statusCode: status,
307
+ at: before?.at ?? nowAt.toISOString(),
308
+ probes,
309
+ retryAt: new Date(nowMs + resourceRetryMs(probes, policy)).toISOString(),
310
+ };
311
+ await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: entry })}::jsonb,
312
+ updated_at = ${nowAt} where ${C.key} = ${key}`);
313
+ if (before) result.resourceStillStopped = { resource: where, retryAt: entry.retryAt };
314
+ else result.resourceStopped = where;
275
315
  return result;
276
316
  }
277
317
  if (!state.haltedReason) {
@@ -301,7 +341,9 @@ export function createCallGuard({
301
341
  if (!reports) return;
302
342
  const cause = status ? `HTTP ${status}` : failureKind;
303
343
  if (outcome.halted) log.error?.(`${tag} ${name} refused the connection (${cause}). Calls are stopped until a person checks the ${name} settings or connects again.`);
304
- if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped until a person checks the connection.`);
344
+ if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped; Flow tries again by itself, or a person connects again.`);
345
+ if (outcome.resourceStillStopped) log.warn?.(`${tag} ${name} still refuses calls to ${where} (${cause}). Flow tries again at ${outcome.resourceStillStopped.retryAt}.`);
346
+ if (outcome.resourceResumed) log.info?.(`${tag} ${name} allows calls to ${outcome.resourceResumed} again.`);
305
347
  if (outcome.opened) {
306
348
  const line = `${tag} ${name} calls failed repeatedly (last: ${cause}${where ? ` on ${where}` : ''}). Calls pause until ${outcome.opened.until}.`;
307
349
  if (outcome.opened.opens === 1) log.error?.(line);
@@ -352,7 +394,7 @@ export function createCallGuard({
352
394
  circuitOpenUntil: openUntil > nowMs ? iso(openUntil) : null,
353
395
  haltedReason: state.haltedReason,
354
396
  haltedAt: iso(state.haltedAt),
355
- stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry })),
397
+ stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry, retryAt: Number.isFinite(resourceRetryAt(entry, policy)) ? iso(resourceRetryAt(entry, policy)) : null })),
356
398
  failedToday: await failuresSince(db, key, startOfUtcDay(nowMs)),
357
399
  dailyFailureBudget: policy.dailyFailureBudget,
358
400
  };
@@ -27,7 +27,8 @@ export const XERO_ENDPOINTS = Object.freeze({
27
27
  });
28
28
 
29
29
  export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
30
- export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
30
+ // accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
31
+ export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
31
32
  export const DEFAULT_XERO_DAY_LIMIT = 1000;
32
33
  export const DEFAULT_DUE_DAYS = 30;
33
34
  export const DEFAULT_MIRROR_PAGE_SIZE = 200;
package/server/ui/app.js CHANGED
@@ -1001,16 +1001,20 @@ function xeroConnection(organisations, canChange) {
1001
1001
  let hint;
1002
1002
  let control = null;
1003
1003
  let stopped = false;
1004
+ let refused = false;
1004
1005
  try {
1005
1006
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
1006
1007
  if (status.connected && status.calls?.stopped) {
1007
1008
  // A refused connection stopped calls (the call guard): say so, and
1008
1009
  // make Connect again the main button, which starts them again.
1009
1010
  const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
1011
+ // One refused resource stops only that resource, and Flow tries it
1012
+ // again by itself: a calm note, not a stop.
1010
1013
  hint = status.calls.all
1011
1014
  ? `Xero refused Flow${at}. Flow stopped all calls.`
1012
- : `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow stopped those calls.`;
1013
- stopped = true;
1015
+ : `Connected. Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Other calls still work. Flow tries again by itself.`;
1016
+ stopped = status.calls.all;
1017
+ refused = !status.calls.all;
1014
1018
  } else if (status.connected) {
1015
1019
  const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
1016
1020
  hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
@@ -1018,7 +1022,7 @@ function xeroConnection(organisations, canChange) {
1018
1022
  hint = 'Not connected.';
1019
1023
  }
1020
1024
  if (canChange) {
1021
- control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
1025
+ control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
1022
1026
  try {
1023
1027
  const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
1024
1028
  location.assign(authorizeUrl);
@@ -17,9 +17,10 @@ function loadScript({ src, integrity }) {
17
17
  });
18
18
  }
19
19
 
20
- // "Notify me": the form's email field (Sentry's own words) starts empty and
21
- // is optional. A person who types an address there asks to hear back; Flow
22
- // tags the report notify=yes, so the support desk sees it on the task.
20
+ // "Notify me": the form's email field (Sentry's own words) starts with the
21
+ // signed-in person's email. A report sent with an address asks to hear back;
22
+ // Flow tags it notify=yes, so the support desk sees it on the task. A person
23
+ // who clears the field sends notify=no.
23
24
  // Sentry's form has no tick box of its own; this keeps to its stock options.
24
25
  export function tagNotify(event) {
25
26
  if (event?.type !== 'feedback') return;
@@ -71,9 +72,8 @@ export async function startMonitoring({ flow, person }) {
71
72
  showBranding: false,
72
73
  // Flow already knows who is signed in (setUser below).
73
74
  showName: false,
74
- // The email field is the opt-in (tagNotify), so it is not filled from
75
- // the signed-in person: a key setUser never sets.
76
- useSentryUser: { name: 'username', email: 'notify_email' },
75
+ // The form fills in the signed-in person's name and email (setUser).
76
+ useSentryUser: { name: 'username', email: 'email' },
77
77
  enableScreenshot: true,
78
78
  triggerLabel: 'Report a problem',
79
79
  formTitle: 'Report a problem',
@@ -102,16 +102,17 @@ export async function startMonitoring({ flow, person }) {
102
102
  });
103
103
  Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
104
104
  // Who reported or met the error, so the lab can follow up.
105
- Sentry.setUser({ id: person.id, email: person.email });
105
+ Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
106
106
  const feedback = config.feedback ? Sentry.getFeedback() : null;
107
107
  if (!feedback) return null;
108
- // One form, made on first use and opened again after that.
108
+ // A new form each time. Sentry's form keeps a refused screenshot's error
109
+ // ("Permission denied by user") until the next click, so a reused form
110
+ // would show it before the person tries again.
109
111
  let form = null;
110
112
  return async () => {
111
- if (!form) {
112
- form = await feedback.createForm();
113
- form.appendToDom();
114
- }
113
+ form?.removeFromDom();
114
+ form = await feedback.createForm();
115
+ form.appendToDom();
115
116
  form.open();
116
117
  };
117
118
  }