@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.
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
9
|
+
"version": "2026.10.40",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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: '
|
|
146
|
-
title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1013
|
-
stopped =
|
|
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);
|
package/server/ui/monitoring.js
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
// tags
|
|
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
|
|
75
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
113
|
+
form?.removeFromDom();
|
|
114
|
+
form = await feedback.createForm();
|
|
115
|
+
form.appendToDom();
|
|
115
116
|
form.open();
|
|
116
117
|
};
|
|
117
118
|
}
|