@arcforgelabs/flow 2026.10.53 → 2026.11.1

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 (38) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +7 -4
  3. package/npm-shrinkwrap.json +442 -4
  4. package/package.json +8 -3
  5. package/server/agents/auth-worker.js +7 -0
  6. package/server/agents/auth.js +50 -0
  7. package/server/agents/gate.js +138 -0
  8. package/server/agents/grants.js +51 -0
  9. package/server/agents/reads.js +47 -0
  10. package/server/agents/server.js +74 -0
  11. package/server/agents/settings.js +24 -0
  12. package/server/app.js +9 -0
  13. package/server/core/ledger.js +90 -16
  14. package/server/core/line-mapping.js +2 -2
  15. package/server/core/read-before-act.js +2 -2
  16. package/server/core/reconcile.js +15 -1
  17. package/server/core/sending.js +20 -2
  18. package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
  19. package/server/db/migrations/0026_bitter_sersi.sql +38 -0
  20. package/server/db/migrations/meta/0025_snapshot.json +4709 -0
  21. package/server/db/migrations/meta/0026_snapshot.json +4926 -0
  22. package/server/db/migrations/meta/_journal.json +14 -0
  23. package/server/db/schema.js +38 -1
  24. package/server/index.js +9 -1
  25. package/server/integrations/port.js +1 -0
  26. package/server/integrations/seazona/history/backfill.js +3 -1
  27. package/server/integrations/seazona/history/index.js +4 -7
  28. package/server/integrations/seazona/index.js +5 -1
  29. package/server/integrations/seazona/lanes.js +44 -27
  30. package/server/integrations/seazona/ledger.js +17 -6
  31. package/server/integrations/seazona/reconcile.js +12 -0
  32. package/server/rate-limit.js +2 -1
  33. package/server/ui/app.js +74 -62
  34. package/server/ui/flow.css +36 -5
  35. package/server/ui/invoicing.js +34 -7
  36. package/server/ui/mobile.js +17 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/sync-status.js +67 -0
@@ -1154,20 +1154,31 @@ function draftView(invoice, ui, context, redraw) {
1154
1154
  const sendButton = context.canIssue && to && ready ? h('button', {
1155
1155
  class: 'fx-btn fx-btn--primary', type: 'button',
1156
1156
  onclick: async () => {
1157
- if (changed() && !(await save())) return;
1158
- const ok = await ui.dialog({
1159
- title: `Send this invoice to ${to}?`,
1160
- body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
1161
- confirm: emails ? 'Send and email' : 'Send',
1162
- });
1163
- if (!ok) return;
1157
+ if (sendButton.disabled) return;
1158
+ sendButton.disabled = true;
1159
+ const label = sendButton.textContent;
1160
+ let cancelled = false;
1164
1161
  try {
1162
+ if (changed() && !(await save())) return;
1163
+ const ok = await ui.dialog({
1164
+ title: `Send this invoice to ${to}?`,
1165
+ body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
1166
+ confirm: emails ? 'Send and email' : 'Send',
1167
+ });
1168
+ if (!ok) { cancelled = true; return; }
1169
+ sendButton.setAttribute('aria-busy', 'true');
1170
+ sendButton.replaceChildren(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Sending…');
1165
1171
  await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
1166
1172
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
1167
1173
  toast(`${fresh.number} is on the way to ${to}.`, 'success');
1168
1174
  openNext(ui, invoice.id);
1169
1175
  } catch (error) {
1170
1176
  toast(error.message, 'danger', { stay: true });
1177
+ } finally {
1178
+ sendButton.disabled = false;
1179
+ sendButton.removeAttribute('aria-busy');
1180
+ sendButton.textContent = label;
1181
+ if (cancelled && sendButton.isConnected && document.activeElement === document.body) sendButton.focus();
1171
1182
  }
1172
1183
  },
1173
1184
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
@@ -1423,6 +1434,21 @@ function issuedView(invoice, ui, context, redraw) {
1423
1434
  ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1424
1435
  const preview = previewButton(ui, invoice.id);
1425
1436
  actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
1437
+ // The same problem notice as a draft. A matched Xero invoice gets the
1438
+ // same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
1439
+ const found = invoice.problems ?? [];
1440
+ const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
1441
+ const problemNotice = found.length
1442
+ ? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
1443
+ : null;
1444
+ const matched = found.find((p) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.xero?.invoiceId ?? ''));
1445
+ const openXero = matched ? h('a', {
1446
+ class: 'fx-btn fx-btn--sm fx-btn--ghost',
1447
+ href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
1448
+ target: '_blank',
1449
+ rel: 'noopener',
1450
+ }, 'Open in Xero') : null;
1451
+ const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
1426
1452
  const header = h('header', { class: 'fx-ph' },
1427
1453
  h('div', { class: 'fx-ph__text' },
1428
1454
  h('h1', { class: 'fx-title' }, invoice.number),
@@ -1449,6 +1475,7 @@ function issuedView(invoice, ui, context, redraw) {
1449
1475
  chrome.record,
1450
1476
  chrome.pager,
1451
1477
  header,
1478
+ problemRow,
1452
1479
  h('div', { class: 'fx-invoice__grid' },
1453
1480
  h('div', { class: 'fx-invoice__main' },
1454
1481
  h('section', { class: 'fx-card fx-doc' },
@@ -618,6 +618,23 @@ export const RULES = {
618
618
  ],
619
619
  "card": "Sidecar"
620
620
  },
621
+ "syncStatus": {
622
+ "component": "syncStatus",
623
+ "direction": "Layers",
624
+ "phone": {
625
+ "layout": "wrap"
626
+ },
627
+ "tablet": {
628
+ "layout": "row"
629
+ },
630
+ "desk": {
631
+ "layout": "row"
632
+ },
633
+ "touch": 44,
634
+ "pageOverflow": "none",
635
+ "screens": [],
636
+ "card": "SyncStatus"
637
+ },
621
638
  "fx-tabs": {
622
639
  "component": "fx-tabs",
623
640
  "direction": "scrolling tabs",
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
22
22
  const logoDir = join(here, 'logos');
23
23
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
24
24
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
25
- export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
+ export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
26
26
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
27
27
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
28
28
  const MAX_AGE = '1h';
@@ -0,0 +1,67 @@
1
+ import { h } from './kit.js';
2
+ import { applyPhoneRule } from './mobile.js';
3
+
4
+ const DAY = 86_400_000;
5
+ const words = ['Polishing', 'Glazing', 'Milling', 'Sintering'];
6
+ const date = value => value ? new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '';
7
+ const duration = ms => { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
8
+
9
+ // Neutral, read-only view of server state. No inferred event history. The caller
10
+ // owns actions and polling; dispose stops the display clock when unmounted.
11
+ export function syncStatus() {
12
+ const word = h('span', { class: 'fx-sync__word' });
13
+ const dots = h('span', { class: 'fx-sync__dots', 'aria-hidden': 'true' }, ...[0, 1, 2].map(() => h('i')));
14
+ const result = h('span');
15
+ const status = h('span', { class: 'fx-sync__status', role: 'status', 'aria-live': 'polite' }, dots, word, result);
16
+ const clock = h('span', { class: 'fx-sync__clock', 'aria-label': 'Elapsed', 'aria-live': 'off' });
17
+ const action = h('span', { class: 'fx-sync__action' });
18
+ const rail = h('progress', { class: 'fx-sync__rail', max: 1, value: 0, 'aria-label': 'Days read' });
19
+ const range = h('span');
20
+ const eta = h('span');
21
+ const track = h('div', { class: 'fx-sync__track' }, rail, h('span', { class: 'fx-sync__range' }, range, eta));
22
+ const element = applyPhoneRule(h('div', { class: 'fx-sync' }, h('div', { class: 'fx-sync__line' }, status, clock, action), track), 'syncStatus');
23
+ let current = {};
24
+ let timer;
25
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)');
26
+ const updateClock = () => {
27
+ const { phase, startedAt, endedAt, nextRunAt, count, from, through, at } = current;
28
+ const active = ['running', 'waiting'].includes(phase);
29
+ const elapsed = (active ? Date.now() : Date.parse(endedAt)) - Date.parse(startedAt);
30
+ clock.textContent = Number.isFinite(elapsed) ? duration(elapsed) : '';
31
+ // Rotating words and ticking clocks are decorative, not live announcements.
32
+ if (phase === 'running' && !count) word.textContent = reduced.matches ? 'Reading' : words[Math.floor(Math.max(0, elapsed) / 2600) % words.length] ?? 'Reading';
33
+ eta.textContent = '';
34
+ if (phase === 'waiting' && nextRunAt) {
35
+ const seconds = Math.max(0, Math.ceil((Date.parse(nextRunAt) - Date.now()) / 1000));
36
+ clock.textContent = `${clock.textContent}${clock.textContent ? ' · ' : ''}${seconds}s until retry`;
37
+ }
38
+ const completed = Date.parse(at) - Date.parse(from);
39
+ const total = Date.parse(through) - Date.parse(from) + DAY;
40
+ if (phase === 'running' && count > 0 && completed > 0 && total > completed && elapsed > 0) {
41
+ eta.textContent = `about ${Math.max(1, Math.ceil(elapsed * (total - completed) / completed / 60000))} min left`;
42
+ }
43
+ };
44
+ const draw = (state, button = null) => {
45
+ current = state;
46
+ const { phase = 'not-started', count, unit = 'jobs', from, through, at, cause } = state;
47
+ element.dataset.phase = phase;
48
+ const running = phase === 'running';
49
+ dots.hidden = !running || count > 0;
50
+ word.setAttribute('aria-hidden', String(running && !count));
51
+ status.setAttribute('aria-label', running && !count ? 'Reading' : '');
52
+ word.textContent = running ? (count > 0 ? `Reading${at ? ` ${date(at)}` : ''}` : 'Reading') : ({ waiting: 'Waiting', stopped: 'Stopped', failed: 'Failed', done: 'Done', loading: 'Checking…' }[phase] ?? 'Not started');
53
+ result.textContent = `${count != null && (count > 0 || !running) ? ` · ${count.toLocaleString('en-AU')} ${unit}` : ''}${cause ? ` · ${cause}` : ''}${phase === 'stopped' && at ? ` · ${date(at)}` : ''}`;
54
+ if (action.firstChild !== button) action.replaceChildren(...(button ? [button] : []));
55
+ const total = Date.parse(through) - Date.parse(from) + DAY;
56
+ track.hidden = !(count > 0 && total > 0);
57
+ rail.value = phase === 'done' ? 1 : Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(from)) / total || 0));
58
+ range.textContent = track.hidden ? '' : `${date(from)} – ${date(through)}`;
59
+ clearInterval(timer);
60
+ updateClock();
61
+ if (['running', 'waiting'].includes(phase)) timer = setInterval(() => {
62
+ if (!element.isConnected) { clearInterval(timer); return; }
63
+ if (!document.hidden) updateClock();
64
+ }, 1000);
65
+ };
66
+ return { element, draw, dispose: () => clearInterval(timer) };
67
+ }