agnets done

This commit is contained in:
2026-08-28 11:02:02 +05:30
parent ab9adde6cd
commit eaf08e061d
58 changed files with 3506 additions and 4308 deletions

View File

@@ -11,16 +11,19 @@
*
* React → base44Client.js → HTTP → Go API → PostgreSQL
*
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
* of the demo user's default preferences, which the synchronous accessor below
* needs before the first response arrives.
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
* loaded by the backend's `make seed`, and nothing in the running app carries a
* copy of it: `store.js` — the localStorage database this replaced — is gone,
* and `seed.js` is now a test fixture that no production module imports.
*
* The one thing still needed before the first response arrives is the *shape*
* of the user's default preferences, because the accessor that reads them is
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
*/
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER } from './seed';
import { DEMO_USER } from './demoUser';
const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
@@ -33,6 +36,14 @@ const ENTITY_NAMES = [
/* Shifts worked, missed and overrun. The operational record behind
attendance and overtime analysis — see `api/attendanceSeed.js`. */
'ShiftRecord',
/* The agent and skill registry the runtime resolves from.
Authored definitions used to be written into the account's preferences,
which the backend stores faithfully and the runtime never reads — so an
agent created in the editor showed as "published" and answered every
request with 404. These are the endpoints that make an authored agent a
real one. */
'AgentDefinition',
'SkillDefinition',
];
const entities = Object.fromEntries(
@@ -210,7 +221,7 @@ const auth = {
* Preferences, read synchronously.
*
* A plain read of the same record `me()` returns, defaulted with the shape
* from `seed.js` so a key the server has never stored still resolves. See the
* from `demoUser.js` so a key the server has never stored still resolves. See the
* note on `currentUser` for why this must not become async.
*/
preferences() {
@@ -327,6 +338,41 @@ const workflows = {
},
};
/* ── Owliver ────────────────────────────────────────────────────────────── */
/**
* What could usefully be asked on this page.
*
* The one read in this file that is not a table. `GET /owliver/suggestions`
* answers with at most three questions, and the server decides all three: it
* filters them against the caller's role through the same policy table every
* other endpoint consults, and — when nothing has been typed — ranks them
* against the organization's actual state in PostgreSQL. A workspace with
* unfinished drafts is asked about drafts; one with unscored candidates is
* asked about screening.
*
* Nothing is ranked, scored, filtered or reordered on this side. That is the
* point: a suggestion is a claim that the reader could usefully ask something,
* and the only thing that knows whether that is true is the thing holding the
* data and the permissions. The panel requests, renders, and runs whichever one
* is chosen through the capability it names.
*
* `query` is what is in the composer. Sent only when it is non-empty: an absent
* query asks the data what to suggest, and an empty string would be a different
* request from the one the caller means.
*/
const owliver = {
/** @param {any} request */
async suggestions({ page, query = '' } = {}) {
if (!page) return [];
const typed = String(query || '').trim();
const data = await request('GET', '/owliver/suggestions', {
query: { page, ...(typed ? { query: typed } : {}) },
});
return Array.isArray(data?.suggestions) ? data.suggestions : [];
},
};
/* ── Integrations & analytics ───────────────────────────────────────────── */
const integrations = {
@@ -344,7 +390,7 @@ const analytics = {
},
};
export const base44 = { entities, auth, integrations, analytics, workflows };
export const base44 = { entities, auth, integrations, analytics, workflows, owliver };
/** Where the entity data actually comes from, for diagnostics. */
export { API_BASE_URL };

39
src/api/demoUser.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* The shape of a signed-in user, before the server has answered.
*
* This is a **default shape, not a record**. The user lives in PostgreSQL and
* arrives from `GET /me`; what is here is the set of keys that must resolve
* during the first render, in the fraction of a second before that response
* lands.
*
* It exists as its own module for one reason: `base44Client.js` needs exactly
* this, and it used to reach into `api/seed.js` to get it — a 1,900-line
* fixture of demo positions, candidates, interviews, staff and shift records,
* every byte of which was then in the production bundle so that three booleans
* could be defaulted. The fixture is still the right thing for the test scripts
* that read it (`scripts/skill-check.mjs`, `scripts/owliver-capture.mjs`); it
* was never the right thing for the running app. `seed.js` re-exports this
* constant, so those scripts are unchanged and the production import chain no
* longer reaches them.
*
* Nothing here is a source of truth for anything. `preferences` is the one part
* that is read: `auth.preferences()` is synchronous — `AssistantPanelContext`
* decides whether Owliver starts open in a `useState` initialiser — so a key
* the server has never stored still has to resolve to something rather than to
* `undefined`.
*/
export const DEMO_USER = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
role: 'admin',
account_type: 'employer',
created_date: '2026-06-01T09:00:00.000Z',
/* Product preferences travel with the account rather than in a store of their
own, so there is one record to persist and one thing to read. */
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};

View File

@@ -109,6 +109,11 @@ const RESOURCE_PATHS = {
User: 'users',
Assignment: 'assignments',
ShiftRecord: 'shift-records',
/* Authored agents and skills. These are the registry the BACKEND runs from:
an agent saved here is one Owliver can be asked to run, which is the whole
difference between this and the preferences blob these used to live in. */
AgentDefinition: 'agent-definitions',
SkillDefinition: 'skill-definitions',
};
/* ── Request ────────────────────────────────────────────────────────────── */

View File

@@ -9,6 +9,7 @@
*/
import { SHIFT_RECORDS } from './attendanceSeed';
import { DEMO_USER } from './demoUser';
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
@@ -530,9 +531,14 @@ const JOB_APPLICATIONS = [
job_posting_id: 'job_security', job_title: 'Event Security Officer',
}),
unscreened({
/* The rejected outcome, and the only one reached without a score: turned
down on the licence requirement before screening ran. Rejection overwrites
whatever stage it was reached from, which is why the funnel can place him
no further than screened. */
id: 'app_kevin', applicant_name: 'Kevin Boyle', email: 'kevin.boyle@email.com',
phone: '+1-415-555-0113', years_experience: 7, english_level: 'native',
job_posting_id: 'job_security', job_title: 'Event Security Officer',
status: 'rejected', updated_date: iso('2026-08-07'),
}),
unscreened({
id: 'app_rosa', applicant_name: 'Rosa Delgado', email: 'rosa.delgado@email.com',
@@ -563,7 +569,11 @@ const JOB_APPLICATIONS = [
selfie_url: 'https://i.pravatar.cc/240?img=33',
job_posting_id: 'job_bartender_corp',
job_title: 'Experienced Bartender – Corporate Events',
status: 'hired',
/* Hired and then rostered. `assigned` is the seventh application status and
the last one nothing exercised: it counts as hired everywhere the product
asks how many people a role has, but takes the candidate out of the
running, and it is the only status backed by a row in Assignment. */
status: 'assigned',
ai_score: 92,
ai_match_label: 'Excellent Match',
ai_summary:
@@ -583,6 +593,9 @@ const JOB_APPLICATIONS = [
/* Event Server – Fine Dining: 3 applicants · 3 screened · 0 hired */
{
/* Shortlisted: the strongest screened candidate on this role, moved forward
but not yet interviewed. Without her the shortlisted stage was empty and
every funnel that counts it reported a permanent zero. */
id: 'app_sofia',
applicant_name: 'Sofia Mendez',
email: 'sofia.mendez@email.com',
@@ -599,7 +612,7 @@ const JOB_APPLICATIONS = [
selfie_url: 'https://i.pravatar.cc/240?img=47',
job_posting_id: 'job_server_fine',
job_title: 'Event Server – Fine Dining',
status: 'ai_screened',
status: 'shortlisted',
ai_score: 89,
ai_match_label: 'Excellent Match',
ai_summary:
@@ -1426,9 +1439,18 @@ const WORKER_PROFILES = [
salary_expectations: '$30–$36/hr',
leadership_potential: 92,
ai_interview_score: 93,
krow_score: 94,
reliability_score: 95,
profile_completion: 100,
krow_score: 95,
reliability_score: 96,
profile_completion: 94,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 98, performance: 93, education: 100,
clientReviews: 98, supervisorReviews: 96,
growth: 100, experience: 80,
},
xp: 1480,
/* Seven years on the floor, as a completed ladder: Server and Leadership to
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
@@ -1479,9 +1501,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 24,
ai_interview_score: 0,
krow_score: 12,
reliability_score: 38,
profile_completion: 62,
krow_score: 30,
reliability_score: 37,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 92, performance: 0, education: 80,
clientReviews: 0, supervisorReviews: 0,
growth: 12, experience: 15,
},
xp: 120,
/* One year in: the Beginner rung of Server, and one Intermediate module
started — so her card reads "Beginner, advancing" rather than a flat
@@ -1537,9 +1568,18 @@ const WORKER_PROFILES = [
salary_expectations: '$26–$32/hr',
leadership_potential: 68,
ai_interview_score: 86,
krow_score: 82,
krow_score: 87,
reliability_score: 91,
profile_completion: 94,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 96, performance: 88, education: 100,
clientReviews: 94, supervisorReviews: 92,
growth: 100, experience: 45,
},
xp: 1180,
/* Server through Intermediate, plus two of the three Advanced modules —
Fine Dining Service Standards is the one thing between him and Advanced,
@@ -1585,9 +1625,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 45,
ai_interview_score: 74,
krow_score: 68,
reliability_score: 84,
profile_completion: 80,
krow_score: 78,
reliability_score: 86,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 93, performance: 81, education: 100,
clientReviews: 90, supervisorReviews: 88,
growth: 76, experience: 25,
},
xp: 760,
completed_courses: completions(
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
@@ -1624,9 +1673,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 20,
ai_interview_score: 0,
krow_score: 41,
reliability_score: 72,
profile_completion: 58,
krow_score: 35,
reliability_score: 42,
profile_completion: 63,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 100, performance: 0, education: 100,
clientReviews: 0, supervisorReviews: 0,
growth: 32, experience: 0,
},
xp: 320,
completed_courses: completions(
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
@@ -1665,7 +1723,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 30,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1700,7 +1767,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 10,
profile_completion: 31,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1735,7 +1811,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 25,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1770,7 +1855,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 20,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1878,33 +1972,56 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
/* ── Signed-in demo user ───────────────────────────────────────────────── */
export const DEMO_USER = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
role: 'admin',
account_type: 'employer',
created_date: iso('2026-06-01'),
/* Product preferences travel with the account rather than in a store of their
own, so there is one record to persist and one thing to read. */
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};
/**
* Re-exported, not declared.
*
* The default user shape moved to `api/demoUser.js` so that `base44Client.js`
* can have it without importing this file — and with it every position,
* candidate, interview and shift record below — into the production bundle.
* The fixtures here are for the test scripts, and they still read
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
*/
export { DEMO_USER } from './demoUser';
/* ── Assignments ───────────────────────────────────────────────────────────
Who is on which position, and until when — the record that turns a hire into
workforce allocation, and the one that says who is therefore not free for
anything else.
Empty by design. This deployment has no assignment records yet: they are
written by the assignment flow, not shipped as seed. The engine reads an
empty set correctly — every position simply reports nobody assigned — and
the UI omits the demand figures rather than inventing coverage that does
not exist. */
const ASSIGNMENTS = [];
Was empty by design, on the reasoning that assignments are written by the
assignment flow rather than shipped. That held until it turned out to be the
reason nothing exercised `assigned`: the funnel dropped that status out of
every stage bucket, and the agent's own candidate lookup offered somebody
already working a shift as a person to chase. Neither was visible against a
fixture that never produced one.
So: exactly one, the minimum that makes the status real. Every reader still
has to handle the empty case, because eight of the nine positions have no
assignment and report nobody assigned. */
const ASSIGNMENTS = [
/* One assignment, for the one application at `assigned`.
Marco is the right person to carry it: he is the control in the attendance
data, with a recurring corporate bartending pattern across the whole
window, so an open-ended assignment to that position is what his shift
records already describe. `ends_at` is null — the assignment is ongoing,
which is also the case the readers have to handle.
`worker_profile_id` is absent by design: Marco is Staff, and the talent
pool and the roster are deliberately different people. */
{
id: 'assign_marco_bartender',
job_posting_id: 'job_bartender_corp',
application_id: 'app_marco',
worker_email: 'marco.rivera@email.com',
worker_name: 'Marco Rivera',
starts_at: iso('2026-07-01'),
ends_at: null,
status: 'active',
source: 'seed',
match_score: 92,
created_date: iso('2026-07-01'),
updated_date: iso('2026-07-01'),
},
];
/* Attendance lives in its own module: it is generated rather than written out,
and its dates are anchored to now rather than to this file's fixed calendar.

View File

@@ -1,173 +0,0 @@
/**
* In-memory entity store backing the KROW demo.
*
* Mirrors the Base44 entity API the app was built against
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
* page and component consumes data exactly as it did against the real backend.
* Records live in memory and are mirrored to localStorage, so demo edits —
* screening a candidate, hiring, creating a position — survive a reload.
*/
const STORAGE_KEY = 'krow_demo_db';
const STORAGE_VERSION = 8;
/** Simulated network latency, in ms, so loading states are real. */
const LATENCY = { read: 140, write: 220 };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Structured clone with a JSON fallback for older Safari. */
const clone = (value) =>
typeof structuredClone === 'function'
? structuredClone(value)
: JSON.parse(JSON.stringify(value));
let _counter = 0;
export function makeId(prefix = 'rec') {
_counter += 1;
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
}
/* ── Persistence ───────────────────────────────────────────────────────── */
let db = {};
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
} catch {
// Private browsing / quota — the demo still works from memory.
}
}
function readPersisted() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
} catch {
return null;
}
}
/**
* Loads the store: persisted snapshot if one matches the current seed version,
* otherwise the seed itself.
*/
export function initStore(seed) {
db = readPersisted() || clone(seed);
// A seed that gained an entity after the snapshot was written still resolves.
for (const [name, records] of Object.entries(seed)) {
if (!db[name]) db[name] = clone(records);
}
persist();
}
/** Drops all local edits and restores the shipped demo data. */
export function resetStore(seed) {
db = clone(seed);
persist();
}
/* ── Query helpers ─────────────────────────────────────────────────────── */
/**
* Applies a Base44-style sort string: `'-ai_score'` descending,
* `'created_date'` ascending. Unknown fields leave order untouched.
*/
function applySort(records, sort) {
if (!sort) return records;
const desc = sort.startsWith('-');
const field = desc ? sort.slice(1) : sort;
return [...records].sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
if (av === bv) return 0;
if (av === undefined || av === null) return 1;
if (bv === undefined || bv === null) return -1;
const result = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return desc ? -result : result;
});
}
/** Shallow equality match across every key of `query`, array fields included. */
function matches(record, query) {
return Object.entries(query).every(([key, want]) => {
const got = record?.[key];
if (Array.isArray(want)) return want.includes(got);
return got === want;
});
}
/* ── Entity API ────────────────────────────────────────────────────────── */
/**
* Builds the client surface for one entity. Every method is async and returns
* cloned records, so callers can never mutate the store by reference.
*/
export function createEntity(name) {
const table = () => (db[name] ||= []);
return {
entityName: name,
async list(sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
return clone(applySort(table(), sort).slice(0, limit));
},
async filter(query = {}, sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
const found = table().filter((record) => matches(record, query));
return clone(applySort(found, sort).slice(0, limit));
},
async get(id) {
await delay(LATENCY.read);
const found = table().find((record) => record.id === id);
if (!found) throw new Error(`${name} ${id} not found`);
return clone(found);
},
async create(data) {
await delay(LATENCY.write);
const now = new Date().toISOString();
const record = {
id: makeId(name.toLowerCase()),
created_date: now,
updated_date: now,
...data,
};
table().unshift(record);
persist();
return clone(record);
},
async update(id, data) {
await delay(LATENCY.write);
const index = table().findIndex((record) => record.id === id);
if (index === -1) throw new Error(`${name} ${id} not found`);
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
table()[index] = next;
persist();
return clone(next);
},
async delete(id) {
await delay(LATENCY.write);
db[name] = table().filter((record) => record.id !== id);
persist();
return { id };
},
async bulkCreate(records = []) {
const created = [];
for (const record of records) created.push(await this.create(record));
return created;
},
};
}