@arcforgelabs/flow 2026.10.50 → 2026.10.51

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.
@@ -162,6 +162,13 @@
162
162
  "when": 1792733287865,
163
163
  "tag": "0022_contacts",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "7",
169
+ "when": 1793110575758,
170
+ "tag": "0023_captures",
171
+ "breakpoints": true
165
172
  }
166
173
  ]
167
- }
174
+ }
@@ -858,3 +858,50 @@ export const jobFieldEvents = pgTable('job_field_events', {
858
858
  foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
859
859
  index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
860
860
  ]);
861
+
862
+ // A photo of a handwritten lab form (server/core/captures.js). The bytes live
863
+ // in the object storage adapter; this row keeps the key, size, type and
864
+ // checksum. `read` is what the vision reader returned (server/core/reader.js):
865
+ // the fields, the model, the tokens and the cost. A new row is `reading`
866
+ // while that runs, or `problem` when the reader is off. A person may type
867
+ // the proposal either way. `done` means someone applied or discarded it.
868
+ // The photo bytes are deleted then, and a row with no decision is deleted
869
+ // after 7 days.
870
+ export const CAPTURE_STATES = ['reading', 'ready', 'done', 'problem'];
871
+ export const CAPTURE_SOURCES = ['phone', 'desk', 'api'];
872
+ export const CAPTURE_DECISIONS = ['applied', 'discarded'];
873
+ export const captures = pgTable('captures', {
874
+ id: id(),
875
+ organisationId: organisationId().references(() => organisations.id),
876
+ state: text('state').notNull(),
877
+ source: text('source').notNull(),
878
+ fileKey: text('file_key').notNull(),
879
+ fileType: text('file_type').notNull(),
880
+ fileSize: integer('file_size').notNull(),
881
+ fileSha256: text('file_sha256').notNull(),
882
+ idempotencyKey: text('idempotency_key').notNull(),
883
+ // Fields, model, token counts and cost in micros. Null until a read finishes.
884
+ read: jsonb('read'),
885
+ match: jsonb('match'),
886
+ proposal: jsonb('proposal').notNull().default(sql`'{"action":null,"jobId":null,"changes":[]}'::jsonb`),
887
+ problem: text('problem'),
888
+ decision: text('decision'),
889
+ decidedBy: uuid('decided_by').references(() => people.id, { onDelete: 'set null' }),
890
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
891
+ photoDeletedAt: timestamp('photo_deleted_at', { withTimezone: true }),
892
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
893
+ createdAt: createdAt(),
894
+ updatedAt: updatedAt(),
895
+ }, (t) => [
896
+ unique('captures_org_id').on(t.organisationId, t.id),
897
+ unique('captures_org_idempotency').on(t.organisationId, t.idempotencyKey),
898
+ unique('captures_file_key').on(t.fileKey),
899
+ index('captures_photo_open').on(t.createdAt).where(sql`photo_deleted_at is null`),
900
+ check('captures_state_valid', inList('state', CAPTURE_STATES)),
901
+ check('captures_source_valid', inList('source', CAPTURE_SOURCES)),
902
+ check('captures_decision_valid', sql`${t.decision} is null or ${t.decision} in ('applied', 'discarded')`),
903
+ check('captures_done_has_decision', sql`(${t.state} = 'done') = (${t.decision} is not null)`),
904
+ check('captures_file_size_valid', sql`${t.fileSize} >= 0`),
905
+ check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
906
+ check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
907
+ ]);
package/server/index.js CHANGED
@@ -9,7 +9,12 @@ import { migrateDatabase, openDatabase } from './db/index.js';
9
9
  import {
10
10
  importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
11
11
  } from './import/at-start.js';
12
+ import { createAiPort, resolveReader } from './core/ai.js';
13
+ import { createBedrockClient } from './core/ai-bedrock.js';
14
+ import { createCaptureRetentionWorker } from './core/captures.js';
12
15
  import { createMonitoredLog } from './core/monitoring.js';
16
+ import { createCaptureReader } from './core/reader.js';
17
+ import { resolveObjectStorage } from './core/storage.js';
13
18
  import { createTracing, traceQueries } from './core/tracing.js';
14
19
  import { createPipelineWorker } from './core/pipeline.js';
15
20
  import { createReconcileWorker } from './core/reconcile.js';
@@ -117,6 +122,43 @@ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start(
117
122
  // with Send all ready, to the accounting integration that is on.
118
123
  if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
119
124
 
125
+ // Capture photos (server/core/storage.js). A bad storage setting stops boot.
126
+ // On Replit, and where the local folder cannot be created, the default is
127
+ // off. The retention pass
128
+ // only deletes Flow's own files, so it still runs where background jobs are
129
+ // off: a second copy deleting the same key does nothing.
130
+ let storage = null;
131
+ try {
132
+ const resolved = await resolveObjectStorage({ env: process.env, secrets });
133
+ storage = resolved.storage;
134
+ if (storage) console.log(`[storage] ${resolved.mode}`);
135
+ else console.log(`[storage] captures off (${resolved.reason})`);
136
+ } catch (error) {
137
+ console.error(`[storage] ${error.message}`);
138
+ process.exit(1);
139
+ }
140
+ if (storage) createCaptureRetentionWorker({ db: database.db, storage, log }).start();
141
+
142
+ // The vision reader (server/core/reader.js). A bad region or a model id that
143
+ // is not an Australia profile stops boot. Off, or on without keys, stores
144
+ // the photo and leaves the capture for a person to type.
145
+ let reader = null;
146
+ try {
147
+ const resolved = resolveReader({ env: process.env });
148
+ if (!resolved.on) console.log(`[reader] off (${resolved.reason})`);
149
+ else if (!storage) console.log('[reader] captures off (no file store)');
150
+ else {
151
+ const port = createAiPort(createBedrockClient(resolved));
152
+ reader = createCaptureReader({ db: database.db, storage, port, settings: resolved, log, jobsOn });
153
+ if (jobsOn) reader.worker.start();
154
+ else console.log('[reader] background jobs off: each capture is read in the upload');
155
+ console.log(`[reader] on (${resolved.region})`);
156
+ }
157
+ } catch (error) {
158
+ console.error(`[reader] ${error.message}`);
159
+ process.exit(1);
160
+ }
161
+
120
162
  let auth = null;
121
163
  if (settings.mode === 'clerk') {
122
164
  const { keys } = settings;
@@ -158,7 +200,11 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
158
200
 
159
201
  const updates = createUpdateChecker();
160
202
 
161
- createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing }).listen(port, () => {
203
+ createApp({
204
+ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
205
+ publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
206
+ reader: reader ? { on: true, schedule: reader.schedule } : null,
207
+ }).listen(port, () => {
162
208
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
163
209
  });
164
210
 
package/server/ui/app.js CHANGED
@@ -348,7 +348,7 @@ function renderSignedOut() {
348
348
  return;
349
349
  }
350
350
  $('sign-in-note').textContent = config.publicSignUp
351
- ? 'Sign in, or ask for access with your work email.'
351
+ ? 'New here? Ask for access with your work email.'
352
352
  : 'Sign in with the account you were invited with.';
353
353
  // With public sign-up off the widget never creates a Clerk account: no
354
354
  // sign-up link, and an OAuth sign-in for an unknown email is not turned
@@ -867,6 +867,7 @@ async function renderIntegration(main, id) {
867
867
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
868
868
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
869
869
  on && current.state === 'ready' ? personSettingsSection(current) : null,
870
+ current.id === 'seazona' && current.state === 'ready' ? seazonaHistorySection() : null,
870
871
  current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
871
872
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
872
873
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
@@ -978,6 +979,56 @@ function xeroImportSection(organisations) {
978
979
  return section;
979
980
  }
980
981
 
982
+ // Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
983
+ // from a start day, a few calls a run inside the rate limit. Start and Stop
984
+ // call no Seazona; the background lane reads. While it runs, the row reads
985
+ // its progress again every 30 seconds, from Flow only.
986
+ function seazonaHistorySection() {
987
+ const canAct = ['owner', 'admin'].includes(me?.person?.role);
988
+ const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
989
+ const from = h('input', { class: 'fx-input', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
990
+ const status = h('span', {}, 'Checking…');
991
+ const jobs = h('span', { class: 'fx-num' }, '…');
992
+ const control = h('span', { class: 'fx-row' });
993
+ const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
994
+ const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
995
+ h('h2', { class: 'fx-set__title' }, 'History'),
996
+ settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Each job waits on hold and never drafts by itself.', control }),
997
+ settingRow({ label: 'Jobs read', control: jobs }));
998
+ const press = async (button, path, body) => {
999
+ button.disabled = true;
1000
+ try {
1001
+ await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
1002
+ toast(body ? 'History read started.' : 'History read stopped.', 'success');
1003
+ } catch (error) {
1004
+ toast(error.message, 'danger');
1005
+ if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
1006
+ }
1007
+ load();
1008
+ };
1009
+ const load = async () => {
1010
+ let body;
1011
+ try {
1012
+ body = await flow('/api/seazona/history');
1013
+ } catch (error) {
1014
+ status.replaceChildren(error.message);
1015
+ return;
1016
+ }
1017
+ const run = body.backfill;
1018
+ jobs.replaceChildren(String(body.history?.jobs ?? 0));
1019
+ if (run?.running) {
1020
+ status.replaceChildren(`Reading ${day(run.nextDay ?? run.from)} · ${run.ordersRead} read`);
1021
+ control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
1022
+ setTimeout(() => { if (section.isConnected) load(); }, 30_000);
1023
+ return;
1024
+ }
1025
+ status.replaceChildren(run ? `${run.stopped ? 'Stopped' : 'Done'} · ${run.ordersRead} read from ${day(run.from)}` : 'Not started');
1026
+ control.replaceChildren(from, h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', { from: from.value }) }, 'Start'));
1027
+ };
1028
+ load();
1029
+ return section;
1030
+ }
1031
+
981
1032
  // Once Xero is on: each organisation's connection, the history copy, and
982
1033
  // Connect, which goes to Xero and comes back.
983
1034
  function xeroConnection(organisations, canChange) {
@@ -1242,7 +1293,8 @@ drawModeToggles();
1242
1293
  // the Clerk instance is called.
1243
1294
  const LOCALIZATION = {
1244
1295
  signIn: {
1245
- start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
1296
+ // titleCombined is the sign-in-or-sign-up screen (public sign-up on).
1297
+ start: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
1246
1298
  password: { subtitle: 'Enter the password for your account.' },
1247
1299
  },
1248
1300
  signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },