@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.
- package/SECURITY.md +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +109 -0
- package/server/app.js +13 -0
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +144 -0
- package/server/core/captures.js +397 -0
- package/server/core/reader.js +333 -0
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +47 -0
- package/server/index.js +47 -1
- package/server/ui/app.js +54 -2
package/server/db/schema.js
CHANGED
|
@@ -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({
|
|
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
|
-
? '
|
|
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
|
-
|
|
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.' } },
|