feat(workday): Hanzo AI for Workday — read-only HCM app

@hanzo/workday: an embedded-app panel + read-only OAuth + API-proxy service
over Workday people data, built on @hanzo/ai + @hanzo/iam via api.hanzo.ai.
Mirrors packages/procore's pure-core shape.

- OAuth2 Authorization Code + refresh against the tenant token endpoint
  (…/ccx/oauth2/{tenant}/token) with HTTP Basic client auth (secret in the
  header, never the body); non-rotating refresh carried forward.
- Workday REST wrappers (staffing v6 workers/orgs/directReports, recruiting v4
  requisitions, absenceManagement v1 absence types) with limit/offset + the
  { total, data } envelope, plus RaaS custom-report reads ({ Report_Entry }).
- Pure people-context assembly with honest budget/truncation windowing.
- Five HR-guardrailed AI actions: summarize worker, draft job description,
  org & headcount, answer HR question, draft review feedback.
- Read-only by design: the proxy is GET-only (405 otherwise); write-back is
  documented as a gated future addition.
- 113 vitest tests; tsc --noEmit clean; esbuild build green.

Registers packages/workday in pnpm-workspace.yaml.
This commit is contained in:
Hanzo Dev
2026-07-04 01:55:15 -07:00
parent bb5528f0df
commit 05a35ac763
27 changed files with 3574 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
# Hanzo AI for Workday
AI over your Workday people data — **summarize a worker or team**, **draft a job
description / requisition**, **summarize an org and headcount**, **answer HR /
policy questions**, and **draft review feedback**, plus a freeform "ask about
these people" box. An embedded-app **panel** you host at `workday.hanzo.ai`,
backed by a small **read-only OAuth + API-proxy service** that keeps the Workday
client secret and access tokens server-side.
Built on the published Hanzo SDK:
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
(never an `/api/` prefix). We import it; we do not reimplement the transport.
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
the `hk-…` API key the panel uses as its gateway bearer.
Workday is the enterprise system of record for people data: workers, supervisory
organizations, time off, and recruiting. This app reads those records through the
Workday REST API and RaaS custom reports, and grounds every AI answer in them.
> **Read-only by design.** v1 exposes reads only. Workday writes (requesting time
> off, business-process submissions) are heavy, kick off approval workflows, and
> mutate the system of record — a gated write-back is a deliberate, documented
> future addition (see *Write-back* below), not an oversight.
---
## Architecture
```
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
───────────────────────────────────── ──────────────────────────────
reads Workday via ──► /proxy/* ──────────► injects OAuth Bearer + refresh,
same-origin proxy (cookie) resolves the resource route to a
(never sees a token or the tenant) REST / RaaS URL on the tenant host
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
(@hanzo/ai headless client)
```
- The **panel** never holds a Workday token, the client secret, or even the tenant
name. It reaches Workday only through the same-origin `/proxy/*` endpoint (with
an HttpOnly session cookie), calling *resource* routes (`/proxy/workers`,
`/proxy/organizations`, `/proxy/report/{owner}/{name}`). It talks to
`api.hanzo.ai` directly with the user's pasted Hanzo key.
- The **service** is the only place `WORKDAY_CLIENT_SECRET`, the OAuth tokens, and
the tenant address exist. It runs the install flow, holds the session, refreshes
the access token transparently on expiry, and resolves each resource route to a
Workday REST/RaaS URL. It accepts **GET only**.
### Source layout (all pure logic is unit-tested)
| File | Responsibility |
| --- | --- |
| `src/config.ts` | Tenant address (host + tenant), REST service/version descriptors, OAuth/REST/RaaS URL builders, gateway URL, server-secret config (`readServerConfig` fails fast). |
| `src/workday-oauth.ts` | OAuth2 Authorization-Code shaping: `basicAuthHeader`, `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing + `carryRefreshToken` + expiry math. Pure. |
| `src/workday-api.ts` | REST wrappers (`listWorkers`, `getWorker`, `listDirectReports`, `listSupervisoryOrganizations`, `listJobRequisitions`, `listEligibleAbsenceTypes`) + RaaS (`timeOffReport`) with `limit`/`offset` pagination, and `{ total, data }` / `Report_Entry` parsers. Pure. |
| `src/people.ts` | Worker/org/requisition/absence/report JSON → windowed context text + honest truncation. Pure. |
| `src/hanzo.ts` | Thin over `@hanzo/ai`: HR-guardrailed prompt assembly + the single `ask` path + `listModels`. |
| `src/actions.ts` | The five AI actions (id → prompt) over `ask`. One code path to the model. |
| `src/panel.ts` | Browser proxy-client request shaping (resource routes) + launch-context parsing. Pure. |
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
| `src/server.ts` | Node http service: OAuth install/callback + read-only `/proxy/*`. |
---
## 1. Register a Workday API Client (OAuth)
In your Workday tenant, an integration/security admin registers an **API Client**:
1. Search the task **Register API Client** (or *Register API Client for
Integrations* for client-credentials integrations). For this app, use the
**Authorization Code Grant**:
- Search **Register API Client**, choose **Authorization Code Grant**.
2. Set:
- **Client Grant Type**: *Authorization Code Grant*.
- **Access Token Type**: *Bearer*.
- **Redirection URI** — exactly the URL your service serves the callback at:
```
https://workday.hanzo.ai/oauth/callback
```
(For local development, also add `http://localhost:8791/oauth/callback`.)
- **Scope (Functional Areas)** — grant the areas this app reads: **Staffing**,
**Organizations and Roles**, **Recruiting**, and **Time Off and Leave**. The
client's registered scope governs access; the OAuth `/authorize` call sends
no `scope` param for a standard client.
- **Non-Expiring Refresh Tokens** — enable if you want the refresh token to
persist (Workday refresh tokens do **not** rotate on refresh).
3. On save, Workday shows the **Client ID** and **Client Secret** (the secret is
shown once — store it in KMS). Note your **tenant name** and your **datacenter
host** (the origin your users hit, e.g. `https://wd2-impl-services1.workday.com`
for an implementation tenant, `https://services1.myworkday.com` for production).
### Implementation ("sandbox") vs production
There is no fixed host per environment — each Workday datacenter differs. An
**implementation** tenant carries `-impl` in the host (`isSandboxHost` detects
this). Point the service at whichever host+tenant you are targeting:
| | Host (`WORKDAY_HOST`) | Tenant (`WORKDAY_TENANT`) |
| --- | --- | --- |
| `sandbox` | `https://wd2-impl-services1.workday.com` | your impl tenant |
| `production` | `https://services1.myworkday.com` | your prod tenant |
Develop against the implementation tenant, flip to production when validated.
---
## 2. OAuth flow
Standard **Authorization Code** grant (server-side secret, **HTTP Basic** client
auth on the token endpoint):
1. `GET /oauth/install` → 302 to
`https://{host}/ccx/oauth2/{tenant}/authorize?response_type=code&client_id=…&redirect_uri=…&state=…`.
2. The user consents; Workday redirects back to `GET /oauth/callback?code=…&state=…`.
3. The service verifies `state`, then POSTs to `/ccx/oauth2/{tenant}/token` with
`grant_type=authorization_code`, the `code`, and the same `redirect_uri`. The
**client id + secret ride in the `Authorization: Basic base64(id:secret)`
header — never in the body**.
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
5. Access tokens are short-lived (~1h). The service refreshes with
`grant_type=refresh_token` **before** each proxied call when the token has
expired. Workday does **not** rotate the refresh token, and the refresh
response usually omits it, so the prior refresh token is carried forward
(`carryRefreshToken`).
> In-memory sessions here are for a single instance. For a multi-instance
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set to
> `hanzoai/kv` (Valkey), and read `WORKDAY_CLIENT_SECRET` from KMS
> (`kms.hanzo.ai`) — never from a committed env file.
---
## 3. Workday REST API + RaaS
**REST** reads go through `https://{host}/ccx/api/{service}/{version}/{tenant}/...`.
Each service is versioned independently; list endpoints paginate with `limit`
(≤ 100) + `offset` (0-based) and return a `{ total, data: [...] }` envelope.
| Resource | Service | Endpoint (after the tenant) |
| --- | --- | --- |
| Workers | `staffing/v6` | `GET /workers`, `GET /workers/{id}` |
| Direct reports | `staffing/v6` | `GET /workers/{id}/directReports` |
| Supervisory orgs | `staffing/v6` | `GET /supervisoryOrganizations`, `.../{id}` |
| Job requisitions | `recruiting/v4` | `GET /jobRequisitions`, `.../{id}` |
| Eligible absence types | `absenceManagement/v1` | `GET /workers/{id}/eligibleAbsenceTypes` |
**RaaS** (Report-as-a-Service) reads a customer-defined report as JSON:
```
GET https://{host}/ccx/service/customreport2/{tenant}/{owner}/{report}?format=json
→ { "Report_Entry": [ { … }, … ] }
```
`owner` is the report owner's username; the app forces `format=json` and parses
the `Report_Entry` rows (nested `{ Descriptor }` references are flattened).
### RaaS report setup (time off)
Time-off history and balances are customer-configured, so they are pulled via a
custom report rather than a fixed REST endpoint:
1. In Workday, build a **Custom Report** (e.g. *Team Time Off*) with the fields
you want per row (Worker, Time Off Type, Units, Date).
2. Enable **Web Service** output (the *customreport2* alias) and share the report
with the API Client's ISU / owner account.
3. Call it from the panel via `report(owner, name)` (route
`/proxy/report/{owner}/{name}`); it returns `Report_Entry` rows that feed the
people context under the **Time off** section.
---
## 4. The summarize-a-worker flow
1. Connect the app (`/oauth/install`) so the service holds a Workday token.
2. Open the panel; it lists the tenant's workers. Pick a worker and **Load**. The
panel pulls the worker's profile, direct reports, supervisory organizations,
open requisitions, and eligible absence types through the proxy and assembles a
windowed, truncation-honest context.
3. **Summarize worker** — the model summarizes role, business title, org and
manager, location, and (if reports are loaded) team size and notable roles,
grounded only in the records, never inferring compensation, performance, or any
protected characteristic.
4. **Org & headcount** — the model summarizes the org, headcount breakdown, open
requisitions and openings, and time-off coverage.
5. The other actions — **Draft job description**, **Answer HR question**, and
**Draft review feedback** — run the same way. A freeform box asks anything over
the loaded records.
Every answer is grounded in the fenced Workday records with an honest note when
the context was truncated to fit the model window.
---
## Write-back (documented, not in v1)
Workday writes (e.g. `POST …/requestTimeOff`, business-process submissions) mutate
the system of record and trigger approval workflows. v1 is intentionally
read-only: the proxy accepts `GET` only and returns `405` for other methods. A
write-back — e.g. drafting a job requisition and submitting it — is a deliberate
future addition that must be gated behind an explicit user confirmation, a
narrowly-scoped API Client permission, and an audit log. It is documented here so
its absence is a decision, not a gap.
---
## Build & run
```bash
pnpm install
pnpm --filter @hanzo/workday build # → dist/ (panel + server)
pnpm --filter @hanzo/workday test # vitest
pnpm --filter @hanzo/workday typecheck # tsc --noEmit
# run the service
WORKDAY_HOST=https://wd2-impl-services1.workday.com \
WORKDAY_TENANT=acme \
WORKDAY_CLIENT_ID=… \
WORKDAY_CLIENT_SECRET=… \
WORKDAY_REDIRECT_URI=https://workday.hanzo.ai/oauth/callback \
WORKDAY_ENV=sandbox \
node packages/workday/dist/server.js
```
### Required environment (service)
| Var | Notes |
| --- | --- |
| `WORKDAY_HOST` | Tenant datacenter host origin, e.g. `https://wd2-impl-services1.workday.com`. |
| `WORKDAY_TENANT` | Tenant name, e.g. `acme`. |
| `WORKDAY_CLIENT_ID` | API Client id (public). |
| `WORKDAY_CLIENT_SECRET` | **Server only.** From KMS in production. |
| `WORKDAY_REDIRECT_URI` | Must match the API Client's registered redirect. |
| `WORKDAY_ENV` | `sandbox` (default) or `production` — a label only. |
| `PORT` | Listen port (default `8791`). |
`readServerConfig` throws on any missing value — the service refuses to start
rather than pretend it can address the tenant or complete an OAuth exchange.
---
## Deploy over hanzoai/ingress
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
**hanzoai/static** plugin and run `dist/server.js` as a small service, both behind
**hanzoai/ingress** at `workday.hanzo.ai` (no nginx, no caddy):
- `/` and the static assets → the panel.
- `/oauth/*`, `/proxy/*`, `/healthz` → the service.
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
(`hanzoai/kv`) for multi-instance. Image published to
`ghcr.io/hanzoai/workday:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never
built locally.
---
*Routed through `api.hanzo.ai`. Answers are grounded in your Workday records —
read-only, informational HR support, not an employment, compensation, legal, or
disciplinary decision.*
+86
View File
@@ -0,0 +1,86 @@
// Build @hanzo/workday into dist/: bundle the web panel (app.ts) as ESM for the
// browser, copy index.html (with its entry script stamped) + styles.css, and
// bundle the OAuth + API-proxy server for Node. No framework — esbuild + Node
// stdlib only. @hanzo/ai is bundled into both outputs (it is the headless client
// the panel and server both call).
//
// node build.js → production build
// node build.js --watch → rebuild on change
// HANZO_WORKDAY_BASE=https://localhost:8443 node build.js → dev base (informational)
//
// Output layout:
// dist/index.html dist/app.js dist/styles.css (panel)
// dist/server.js (service)
//
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
// workday.hanzo.ai; the server runs as a small service (OAuth callback + API
// proxy). All model calls go to api.hanzo.ai regardless of the host base.
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_WORKDAY_BASE || 'https://workday.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the panel as ESM for the browser.
const panelCtx = await esbuild.context({
entryPoints: { app: join(src, 'app.ts') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await panelCtx.rebuild();
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
const html = readFileSync(join(src, 'index.html'), 'utf8')
.split('__ENTRY__').join('app.js')
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(dist, 'index.html'), html);
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo AI for Workday built -> dist/ (base ${BASE})`);
console.log(' Panel: dist/index.html · Service: dist/server.js');
if (watch) {
await panelCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await panelCtx.dispose();
await serverCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@hanzo/workday",
"version": "0.1.0",
"description": "Hanzo AI for Workday — AI over your people data: summarize a worker or team, draft a job description / requisition, summarize an org and headcount, answer HR / policy questions, and draft review feedback. An embedded-app panel plus a read-only OAuth + API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"workday",
"hcm",
"hr",
"people",
"ai",
"oauth",
"raas"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/workday"
}
}
+113
View File
@@ -0,0 +1,113 @@
// The AI actions over Workday people records — summarize a worker/team, draft a
// job description / requisition, summarize an org / headcount, answer an HR /
// policy question, and draft review feedback. Each is a prompt template applied to
// the windowed people context via the single `ask` primitive in hanzo.ts. There
// is exactly ONE code path to the model: an action is (id → prompt), and the panel
// and any server-side caller resolve an id here and call `ask`. No action speaks
// to the gateway directly. (A freeform question is `ask` directly, not an action —
// it has no fixed prompt.)
import { ask, type AskOptions, type PeopleMeta } from './hanzo.js';
import type { PeopleContext } from './people.js';
// The action catalog. `id` is the stable key the panel's chips use; `label` is the
// button text; `prompt` is the instruction handed to the model as the task, over
// the fenced people records. Prompts are specific, output-shaped (bullets,
// structured lists), and carry the HR guardrails (no protected characteristics, no
// invented facts, informational only) so results are consistent and safe.
export const ACTIONS = {
summarizeWorker: {
label: 'Summarize worker',
prompt:
'Summarize the worker profile(s) in these records for an HR business ' +
'partner. Cover: role and business title, the organization and manager, ' +
'location, and whether they manage people. If direct reports are shown, note ' +
'team size and the notable roles. Use short bullets, reference each worker by ' +
'name, ground every statement in the records, and do not infer compensation, ' +
'performance, or any protected characteristic. No preamble. If more than one ' +
'worker is shown, summarize each briefly.',
},
draftJobDescription: {
label: 'Draft job description',
prompt:
'Draft a clear, inclusive job description / requisition for the role ' +
'indicated by these records (use the requisition title, organization, and ' +
'location when present). Include: a one-paragraph role summary, key ' +
'responsibilities as bullets, and required and preferred qualifications as ' +
'bullets. Use neutral, bias-free language free of age, gender, or other ' +
'protected-characteristic coded terms, and do not invent compensation, ' +
'benefits, or requirements the records do not support. Return only the job ' +
'description, ready to paste into a Workday requisition.',
},
summarizeOrg: {
label: 'Org & headcount',
prompt:
'Write a concise organization and headcount summary from these records for ' +
'an HR leader. Cover: the organization and its manager, total headcount and ' +
'how the workers break down by role or sub-team, any open requisitions and ' +
'their openings, and anything the time-off records indicate about current ' +
'coverage. Structure as a one-line headline followed by grouped bullets. Base ' +
'every number and statement strictly on the records and say plainly if the ' +
'records look incomplete.',
},
answerHrQuestion: {
label: 'Answer HR question',
prompt:
'Answer the HR or policy question using these Workday records and general HR ' +
'best practice. Ground any employee-specific claim in the records provided ' +
'and cite the worker or organization it comes from; where the answer depends ' +
'on a company policy document not included here, state exactly what policy or ' +
'approval is needed rather than inventing one. Be concise and neutral, and ' +
'note that the answer is informational and may require review by HR or legal ' +
'before any action is taken.',
},
draftReviewFeedback: {
label: 'Draft review feedback',
prompt:
'Draft constructive, balanced performance-review feedback for the worker in ' +
'these records, written from a manager to the employee. Use only what the ' +
'records support (role, responsibilities, organization) and clearly frame ' +
'observations as prompts the manager should confirm with specific examples, ' +
'since these records do not contain performance ratings. Cover strengths, ' +
'areas for growth, and forward-looking goals, in neutral and respectful ' +
'language free of any protected-characteristic reference. Return only the ' +
'feedback draft.',
},
} as const;
// An action id from the catalog.
export type ActionId = keyof typeof ACTIONS;
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
// the panel and the server validate an inbound id here before running it.
export function isActionId(id: string): id is ActionId {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
// actionPrompt resolves an action id to its prompt template. Throws on an unknown
// id (a boundary error, surfaced to the caller) rather than silently running a
// default. Pure.
export function actionPrompt(id: string): string {
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
return ACTIONS[id].prompt;
}
// actionList is the ordered catalog for building the UI (chips) — id + label,
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
export function actionList(): Array<{ id: ActionId; label: string }> {
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
}
// runAction is the single entry point every surface calls: resolve the action's
// prompt and run it over the windowed people context via `ask`. This is the one
// code path from an action id to the model. Async so an unknown id surfaces as a
// rejected promise (not a synchronous throw), giving callers ONE way to handle
// failure: `await`/`.catch`. A gateway error rejects via `ask`.
export async function runAction(
id: string,
ctx: PeopleContext,
meta?: PeopleMeta,
opts: AskOptions = {},
): Promise<string> {
return ask(actionPrompt(id), ctx, meta, opts);
}
+259
View File
@@ -0,0 +1,259 @@
// The Workday panel glue: the embedded/linked-app page that opens over a tenant
// (optionally deep-linked to a worker or organization) and drives the AI actions
// over the tenant's live people records. All the logic-heavy work (action prompts,
// context windowing, chat shaping, proxy request shaping, auth) lives in its own
// tested modules; this file binds them to the DOM. It is the one impure,
// browser-only entry point.
//
// Data flow: the panel reads Workday ONLY through the same-origin server proxy
// (createProxyClient), which holds the OAuth token + tenant address server-side.
// It pulls the selected worker's profile + direct reports + organizations +
// requisitions (+ eligible absence types), assembles a windowed context
// (buildPeopleContext), and runs an action or a freeform question against
// api.hanzo.ai with the pasted Hanzo key. v1 is read-only — there is no write-back.
import { actionList, isActionId, runAction } from './actions.js';
import { ask as askHanzo, listModels, type PeopleMeta } from './hanzo.js';
import {
buildPeopleContext,
contextNote,
workerSection,
directReportSection,
organizationSection,
requisitionSection,
absenceTypeSection,
type PeopleContext,
} from './people.js';
import { DEFAULT_MODEL } from './config.js';
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
import type { Worker } from './workday-api.js';
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
let controller: AbortController | null = null;
window.addEventListener('DOMContentLoaded', () => {
const launch = parseLaunchContext(window.location.search);
const workerEl = $<HTMLSelectElement>('worker');
const loadBtn = $<HTMLButtonElement>('load');
const refreshBtn = $<HTMLButtonElement>('refresh');
const recordsEl = $('records');
const outputEl = $<HTMLTextAreaElement>('output');
const statusEl = $('status');
const modelEl = $<HTMLSelectElement>('model');
const chipRow = $('chips');
const runBtn = $<HTMLButtonElement>('run');
const stopBtn = $<HTMLButtonElement>('stop');
const promptEl = $<HTMLTextAreaElement>('prompt');
const apiKeyEl = $<HTMLInputElement>('apikey');
const saveKeyBtn = $<HTMLButtonElement>('savekey');
const authHint = $('authhint');
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
// Loaded people state: the records that make up the current context, plus the
// worker we're scoped to.
let ctx: PeopleContext | null = null;
let meta: PeopleMeta = {};
// Action chips — derived from the catalog so UI and logic never drift.
for (const a of actionList()) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = a.label;
b.dataset.action = a.id;
b.onclick = () => void run(a.id);
chipRow.appendChild(b);
}
// client builds a proxy client over the same-origin server proxy.
function client(): ProxyClient {
return createProxyClient();
}
loadBtn.onclick = () => void loadWorker();
refreshBtn.onclick = () => void populateWorkers();
runBtn.onclick = () => {
const prompt = promptEl.value.trim();
if (prompt) void ask(prompt);
};
stopBtn.onclick = () => controller?.abort();
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
setStatus('Checking key…');
try {
const models = await validateKey(key);
setApiKey(key);
fillModels(models);
reflectAuth();
setStatus('Key saved.', 'ok');
} catch (e: any) {
setStatus(e?.message || 'Key rejected.', 'error');
}
};
// List the tenant's workers on open; if we launched with a worker, load it.
void populateWorkers().then(() => {
if (launch.workerId) {
workerEl.value = launch.workerId;
void loadWorker();
}
});
// populateWorkers fills the worker picker from the proxy. An auth failure (the
// app isn't connected) leaves the picker empty with a status hint.
async function populateWorkers(): Promise<void> {
setStatus('Loading workers…');
try {
const { items, total } = await client().listWorkers({ limit: 100 });
fillWorkers(items);
setStatus(
items.length
? `Loaded ${items.length}${total > items.length ? ` of ${total}` : ''} workers.`
: 'No workers visible.',
items.length ? 'ok' : 'warn',
);
} catch (e: any) {
setStatus(
e?.message || 'Could not load workers — is the app connected? (Connect on workday.hanzo.ai)',
'error',
);
}
}
// loadWorker pulls the selected worker + direct reports + organizations +
// requisitions + eligible absence types and assembles the context the actions
// run over. Each secondary fetch is tolerated so a worker missing one facet
// still yields a usable context.
async function loadWorker(): Promise<void> {
const workerId = workerEl.value;
if (!workerId) {
setStatus('Pick a worker first.', 'warn');
return;
}
setStatus('Loading Workday records…');
const c = client();
const [worker, reports, orgs, reqs, absence] = await Promise.all([
c.getWorker(workerId).catch(() => null),
c.listDirectReports(workerId, { limit: 100 }).then((r) => r.items).catch(() => [] as Worker[]),
c.listOrganizations({ limit: 100 }).then((r) => r.items).catch(() => []),
c.listRequisitions({ limit: 100 }).then((r) => r.items).catch(() => []),
c.listAbsenceTypes(workerId, { limit: 100 }).then((r) => r.items).catch(() => []),
]);
ctx = buildPeopleContext([
workerSection(worker ? [worker] : []),
directReportSection(reports),
organizationSection(orgs),
requisitionSection(reqs),
absenceTypeSection(absence),
]);
meta = {
subject: worker?.name || undefined,
organization: worker?.organization || undefined,
};
recordsEl.textContent =
`${worker ? '1 worker' : 'no worker'} · ${reports.length} direct reports · ${orgs.length} orgs · ${reqs.length} requisitions · ${absence.length} absence types`;
setStatus(contextNote(ctx));
}
// run executes one of the named actions over the loaded people context.
async function run(actionId: string): Promise<void> {
if (!isActionId(actionId)) return;
await execute((c, m, opts) => runAction(actionId, c, m, opts));
}
// ask executes a freeform question over the loaded people context.
async function ask(prompt: string): Promise<void> {
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
}
// execute is the shared runner: require a loaded context, call the model, show
// the result. Both the chips and freeform ask funnel through here (one path).
async function execute(
call: (
c: PeopleContext,
m: PeopleMeta,
opts: { token: string; model: string; signal: AbortSignal },
) => Promise<string>,
): Promise<void> {
if (!ctx || ctx.totalBlocks === 0) {
setStatus('Load a worker first (pick one and press Load).', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true);
setStatus(contextNote(ctx));
outputEl.value = '';
try {
const text = await call(ctx, meta, {
token: bearer(),
model: modelEl.value || DEFAULT_MODEL,
signal: controller.signal,
});
outputEl.value = text;
setStatus('Done.', 'ok');
} catch (e: any) {
if (e?.name === 'AbortError') setStatus('Stopped.');
else setStatus(e?.message || 'Request failed.', 'error');
} finally {
setBusy(false);
}
}
// ---- small DOM helpers ----------------------------------------------------
function fillWorkers(items: Worker[]): void {
workerEl.innerHTML = '';
for (const w of items) {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = w.businessTitle ? `${w.name}${w.businessTitle}` : w.name || w.id;
workerEl.appendChild(opt);
}
}
async function populateModels(): Promise<void> {
try {
fillModels(await listModels({ token: bearer() }));
} catch {
fillModels([DEFAULT_MODEL]);
}
}
function fillModels(ids: string[]): void {
const list = ids.length ? ids : [DEFAULT_MODEL];
modelEl.innerHTML = '';
for (const id of list) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
modelEl.appendChild(opt);
}
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
}
function reflectAuth(): void {
authHint.textContent = hasApiKey()
? 'Using your saved Hanzo key.'
: 'No key saved — using public models. Paste an hk-… key for your org models.';
}
function setBusy(b: boolean): void {
runBtn.disabled = b;
stopBtn.disabled = !b;
loadBtn.disabled = b;
refreshBtn.disabled = b;
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
}
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
statusEl.textContent = msg;
statusEl.className = `status${kind ? ' ' + kind : ''}`;
}
});
+56
View File
@@ -0,0 +1,56 @@
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway.
// The Workday panel is a static web page (iframe), so the Hanzo credential lives
// in localStorage and is validated by a real /v1/models call — a key that can't
// list models is rejected before it's saved, so the user learns at paste time,
// not at first action. Mirrors @hanzo/procore exactly (one way to hold a key).
//
// The Workday access token (for reading workers/orgs/requisitions) is a SEPARATE
// credential minted server-side by the OAuth flow and held by server.ts; the panel
// reaches Workday only through the server proxy (with its session cookie), so it
// never holds the Workday token or secret. This module is only the Hanzo-gateway
// bearer.
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
import { listModels } from './hanzo.js';
export function getApiKey(): string {
try {
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): void {
try {
const k = key.trim();
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
else localStorage.removeItem(APIKEY_STORAGE_KEY);
} catch {
/* private-mode / storage disabled — the in-memory key still works this session */
}
}
export function clearApiKey(): void {
setApiKey('');
}
export function hasApiKey(): boolean {
return !!getApiKey();
}
// bearer is the credential the chat call sends: the pasted key (or empty for
// anonymous public models). When Hanzo OAuth lands it slots in as the second
// argument to pickBearer with no change to callers.
export function bearer(): string {
return pickBearer(getApiKey(), '');
}
// validateKey confirms a pasted key actually works by listing models with it.
// Returns the models on success so the caller populates the picker in one
// round-trip; throws with the gateway's message on failure.
export async function validateKey(key: string): Promise<string[]> {
const k = key.trim();
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
return listModels({ token: k });
}
+189
View File
@@ -0,0 +1,189 @@
// Workday config — the tenant address (host + tenant name), the REST/RaaS
// endpoint builders, the api.hanzo.ai model gateway, and the server-side secret
// set (Workday OAuth). Endpoints and the bearer choice mirror @hanzo/procore and
// @hanzo/docusign so the productivity suite stays DRY; the HR-specific pieces
// (people-context windowing, the AI actions) live in people.ts / hanzo.ts /
// actions.ts, not here.
//
// Unlike Procore (a fixed production/sandbox host pair), a Workday customer runs
// on a specific datacenter host (e.g. https://wd2-impl-services1.workday.com) with
// a named tenant (e.g. `acme`). Both are deployment config, not hard-coded — every
// URL is built from `host` + `tenant`, so pointing at a different tenant is one
// env change. Implementation ("sandbox") tenants carry `-impl` in the host.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
// never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-workday';
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
// panel is a static web page (iframe), not a host with roamingSettings, so the
// zero-setup credential lives in localStorage — same as @hanzo/procore.
export const APIKEY_STORAGE_KEY = 'hanzo.workday.apiKey';
// People text budget. A tenant has thousands of workers; the whole corpus won't
// fit a model window and shouldn't be sent. This caps the characters of Workday
// text we attach to any one request — chosen so it fits comfortably inside a
// modern context window alongside the reply, and is honest rather than optimal
// (we truncate visibly, never drop silently).
export const PEOPLE_CHAR_BUDGET = 60_000;
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
// key wins over an OAuth token (an explicit key is a deliberate override), else
// the token, else empty (anonymous — the gateway still serves public models).
// Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Workday tenant address -----------------------------------------------
// normHost strips a trailing slash so `${host}${path}` never doubles the slash.
// The ONE place a Workday host is normalized. Pure.
export function normHost(host: string): string {
return host.replace(/\/+$/, '');
}
// A Workday REST API is addressed by a service name + version, both in the path.
// Workday versions each service independently (staffing v6, recruiting v4,
// absenceManagement v1), so the pair travels together.
export interface WorkdayService {
api: string;
version: string;
}
// The REST services this app reads. One value per service — we never bump to a
// speculative version; when Workday ships a new version we change it here once.
export const STAFFING: WorkdayService = { api: 'staffing', version: 'v6' };
export const RECRUITING: WorkdayService = { api: 'recruiting', version: 'v4' };
export const ABSENCE: WorkdayService = { api: 'absenceManagement', version: 'v1' };
export const COMMON: WorkdayService = { api: 'common', version: 'v1' };
// oauthBaseUrl is the tenant's OAuth 2.0 root: `${host}/ccx/oauth2/{tenant}`. The
// /authorize and /token endpoints hang off it (built in workday-oauth.ts). Pure.
export function oauthBaseUrl(host: string, tenant: string): string {
return `${normHost(host)}/ccx/oauth2/${tenant}`;
}
// restPath builds the host-relative REST path for a service + sub-path:
// `/ccx/api/{api}/{version}/{tenant}{sub}`. Host-agnostic on purpose so the same
// builder serves the server (which prepends the host) and any path-only consumer.
// Pure — unit-tested.
export function restPath(tenant: string, service: WorkdayService, sub = ''): string {
return `/ccx/api/${service.api}/${service.version}/${tenant}${sub}`;
}
// restUrl is the absolute REST URL for a service + sub-path on a host. Pure.
export function restUrl(host: string, tenant: string, service: WorkdayService, sub = ''): string {
return `${normHost(host)}${restPath(tenant, service, sub)}`;
}
// raasPath builds the host-relative Report-as-a-Service path for a custom report:
// `/ccx/service/customreport2/{tenant}/{owner}/{report}`. `owner` is the report
// owner's username (the ISU/account the report is shared from). Pure.
export function raasPath(tenant: string, owner: string, report: string): string {
return `/ccx/service/customreport2/${tenant}/${owner}/${report}`;
}
// raasUrl is the absolute RaaS URL for a custom report on a host. Pure.
export function raasUrl(host: string, tenant: string, owner: string, report: string): string {
return `${normHost(host)}${raasPath(tenant, owner, report)}`;
}
// The OAuth scopes the app requests. Workday's Authorization Code grant governs
// access by the API Client's registered functional-area scope in the tenant, so
// the authorize call omits `scope` for a standard client; it is only appended
// when non-empty, for forward-compat with fine-grained scopes.
export const OAUTH_SCOPES = [] as const;
// ---- Environment label ----------------------------------------------------
// Which environment a deployment targets. Purely a label for the UI/logs —
// Workday has no fixed host per environment (each datacenter differs); sandbox is
// an implementation tenant (host carries `-impl`).
export type WorkdayEnv = 'sandbox' | 'production';
// isWorkdayEnv narrows an arbitrary string to a WorkdayEnv, else undefined-safe.
export function isWorkdayEnv(v: string | undefined): v is WorkdayEnv {
return v === 'sandbox' || v === 'production';
}
// isSandboxHost reports whether a Workday host is an implementation ("sandbox")
// tenant — Workday implementation hosts carry `-impl` (e.g. wd2-impl-services1).
// Used to sanity-check that env label and host agree. Pure.
export function isSandboxHost(host: string): boolean {
return /-impl/i.test(host);
}
// ---- Server-side configuration (Workday OAuth) ----------------------------
//
// Read from the environment by src/server.ts. These NEVER reach the browser
// bundle: the client id + host + tenant are effectively public, but the client
// secret is server-only and is validated to be present before the server will
// start (readServerConfig throws on a missing secret). This is the ONLY place the
// secret exists.
export interface ServerConfig {
/** Workday datacenter host origin, e.g. https://wd2-impl-services1.workday.com */
workdayHost: string;
/** Workday tenant name, e.g. acme */
workdayTenant: string;
/** Workday OAuth client id (public). */
workdayClientId: string;
/** Workday OAuth client secret (SERVER ONLY — token exchange + refresh). */
workdayClientSecret: string;
/** OAuth redirect registered on the API Client (e.g. https://workday.hanzo.ai/oauth/callback). */
workdayRedirectUri: string;
/** Environment label: 'sandbox' (implementation) or 'production'. */
workdayEnv: WorkdayEnv;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required Workday value is missing — a
// server that cannot address the tenant or exchange OAuth codes must not pretend
// to start. Pure given an env map, so it is unit-tested without touching
// process.env. The host is normalized here at the one boundary.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const workdayHost = env.WORKDAY_HOST;
const workdayTenant = env.WORKDAY_TENANT;
const workdayClientId = env.WORKDAY_CLIENT_ID;
const workdayClientSecret = env.WORKDAY_CLIENT_SECRET;
const workdayRedirectUri = env.WORKDAY_REDIRECT_URI;
const missing: string[] = [];
if (!workdayHost) missing.push('WORKDAY_HOST');
if (!workdayTenant) missing.push('WORKDAY_TENANT');
if (!workdayClientId) missing.push('WORKDAY_CLIENT_ID');
if (!workdayClientSecret) missing.push('WORKDAY_CLIENT_SECRET');
if (!workdayRedirectUri) missing.push('WORKDAY_REDIRECT_URI');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
return {
workdayHost: normHost(workdayHost!),
workdayTenant: workdayTenant!,
workdayClientId: workdayClientId!,
workdayClientSecret: workdayClientSecret!,
workdayRedirectUri: workdayRedirectUri!,
workdayEnv: isWorkdayEnv(env.WORKDAY_ENV) ? env.WORKDAY_ENV : 'sandbox',
port: Number(env.PORT) || 8791,
};
}
+149
View File
@@ -0,0 +1,149 @@
// The Hanzo call and its request/response shaping over Workday PEOPLE records —
// thin over the published `@hanzo/ai` headless client, host-agnostic, and fully
// unit-testable (no Workday SDK, no DOM). The panel and the server are the glue
// that read a tenant's records and hand them here. Speaks the OpenAI-compatible
// /v1 wire protocol the whole Hanzo suite uses (identical to @hanzo/procore) so
// the gateway sees one shape from every surface.
//
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token })
// → .chat.completions.create({ model, messages }) + .models.list(). We import it
// and do NOT reimplement the transport. This module adds only the people-aware
// prompt assembly + the single `ask` primitive the actions layer on.
import {
createAiClient,
type AiClient,
type ChatCompletion,
type ChatCompletionMessage,
} from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
import { contextNote, type PeopleContext } from './people.js';
// SYSTEM_PROMPT grounds every answer in the Workday records and, critically for
// HR, guards the personal data they contain. It forbids inventing facts nobody
// recorded, forbids inferring protected characteristics / health / performance
// beyond the records, and states plainly that the output is informational support
// for HR professionals — not an employment, compensation, legal, or disciplinary
// decision. A people tool that pretends otherwise is a liability.
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant that helps HR and people teams work with their ' +
'Workday tenant — worker profiles, organizations, headcount, time off, and job ' +
'requisitions. Work ONLY from the Workday records provided below — never invent ' +
'people, titles, dates, compensation, or facts that are not supported by the ' +
'records. These records contain personal employee data: handle it discreetly, ' +
'never infer or output protected characteristics, health, or performance ' +
'conclusions that are not present, and reference workers only as the records ' +
'name them. Be concise, precise, and neutral. If the records are marked ' +
'truncated and a complete answer needs the omitted part, say so plainly rather ' +
'than guessing. You provide informational support to HR professionals — not ' +
'employment, compensation, legal, or disciplinary decisions, which require human ' +
'judgment and review.';
// ---- Prompt assembly ------------------------------------------------------
// Optional structured context about the scope (tenant, organization, subject
// worker) the Workday API supplies. Attached as a short header above the records
// so answers can reference the scope without the model guessing.
export interface PeopleMeta {
tenant?: string;
organization?: string;
subject?: string;
}
function peopleHeader(meta: PeopleMeta | undefined): string {
if (!meta) return '';
const parts: string[] = [];
if (meta.subject) parts.push(`Subject: ${meta.subject}`);
if (meta.organization) parts.push(`Organization: ${meta.organization}`);
if (meta.tenant) parts.push(`Tenant: ${meta.tenant}`);
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
}
// buildMessages turns a task (the user's question, or one of the action prompts)
// plus the windowed people context + optional metadata into the OpenAI-compatible
// message list. The records are fenced so the model treats them as data, not
// instructions, and the honest context note rides inside the user turn so it is
// never lost. Pure — unit-tested.
export function buildMessages(
task: string,
ctx: PeopleContext,
meta?: PeopleMeta,
): ChatCompletionMessage[] {
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
const header = peopleHeader(meta);
const user: ChatCompletionMessage = {
role: 'user',
content:
`${header}${contextNote(ctx)}\n\n` +
`---- workday records ----\n${ctx.text}\n---- end workday records ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws
// on empty content so callers surface the real gateway state. Pure — unit-tested.
export function extractContent(res: ChatCompletion): string {
const msg = res?.choices?.[0]?.message;
const content = msg?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
text = content.map((part) => (part?.type === 'text' ? part.text : '')).join('');
}
if (text.length === 0) throw new Error('Hanzo API returned no content');
return text;
}
// ---- The single call path -------------------------------------------------
export interface AskOptions {
model?: string;
temperature?: number;
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a
// fresh createAiClient pointed at the gateway with the caller's bearer.
function client(opts: AskOptions): AiClient {
return (
opts.client ??
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
);
}
// ask runs ONE non-streaming completion over the people context and returns the
// answer. This is the single path from every Workday surface to the model — the
// actions, a freeform question, and any server-side call all funnel here. token
// may be empty (the gateway serves anonymous/limited models).
export async function ask(
task: string,
ctx: PeopleContext,
meta: PeopleMeta | undefined,
opts: AskOptions = {},
): Promise<string> {
const res = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages: buildMessages(task, ctx, meta),
stream: false,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
},
{ signal: opts.signal },
);
return extractContent(res);
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the headless client. The endpoint is org-scoped by the bearer; an empty token
// lists public models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for Workday</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host">Workday</span>
</header>
<div class="row scope">
<select id="worker" aria-label="Worker"></select>
<button id="load" class="secondary">Load</button>
<button id="refresh" class="secondary" title="Reload the worker list">Refresh</button>
</div>
<div class="records" id="records"></div>
<!-- One-click actions over the loaded worker/team. Chips are built from the catalog. -->
<div class="chips" id="chips"></div>
<label for="prompt">Ask about these people</label>
<textarea id="prompt" placeholder="e.g. Summarize this team and its open roles. · What time-off types is this worker eligible for? · Draft a job description for the open requisition."></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<details>
<summary>Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" class="hint"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's analysis appears here."></textarea>
<footer>Routed through api.hanzo.ai · grounded in your Workday records · read-only, informational HR support — not an employment, compensation, or legal decision.</footer>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
// Public surface of @hanzo/workday: the pure, host-agnostic modules. The browser
// panel (app.ts) and the Node service (server.ts) are entry points, not
// re-exported here. A consumer embedding the people-analysis pipeline in their own
// service imports from here.
export * from './config.js';
export * from './workday-oauth.js';
export * from './workday-api.js';
export * from './people.js';
export * from './hanzo.js';
export * from './actions.js';
+138
View File
@@ -0,0 +1,138 @@
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the
// logic is unit-tested. The panel reaches Workday ONLY through the server proxy
// (same-origin semantic routes under /proxy/*), which injects the OAuth Bearer +
// refreshes it and owns the tenant/host; the browser never holds a Workday token
// and never needs to know the tenant name.
//
// The proxy exposes RESOURCE routes (/proxy/workers, /proxy/organizations, …), not
// raw Workday paths, so the tenant address stays server-side. This module maps
// each resource route to its response parser.
import {
parseWorkers,
parseWorker,
parseOrganizations,
parseOrganization,
parseJobRequisitions,
parseAbsenceTypes,
parseReportEntries,
parseTotal,
pageParams,
type Worker,
type Organization,
type JobRequisition,
type AbsenceType,
type ReportRow,
type Page,
} from './workday-api.js';
// The panel launch context: an optional deep-link to a worker or organization in
// the URL query. Both are optional — without them the user picks from the list.
export interface LaunchContext {
workerId: string;
organizationId: string;
}
// parseLaunchContext reads worker_id / organization_id from a location search
// string, accepting camelCase fallbacks. Pure — unit-tested with plain strings.
export function parseLaunchContext(search: string): LaunchContext {
const q = new URLSearchParams(search);
return {
workerId: q.get('worker_id') ?? q.get('workerId') ?? '',
organizationId: q.get('organization_id') ?? q.get('organizationId') ?? '',
};
}
// The panel's Workday client: reads through the same-origin server proxy. Each
// call rides the session cookie (credentials: 'include'); the proxy injects the
// Bearer + the tenant address. Pure over an injected fetch + base, so request
// shaping is unit-testable.
export interface ProxyClient {
listWorkers(page?: Page): Promise<{ items: Worker[]; total: number }>;
getWorker(workerId: number | string): Promise<Worker>;
listDirectReports(workerId: number | string, page?: Page): Promise<{ items: Worker[]; total: number }>;
listOrganizations(page?: Page): Promise<{ items: Organization[]; total: number }>;
getOrganization(orgId: number | string): Promise<Organization>;
listRequisitions(page?: Page): Promise<{ items: JobRequisition[]; total: number }>;
listAbsenceTypes(workerId: number | string, page?: Page): Promise<{ items: AbsenceType[]; total: number }>;
report(owner: string, name: string, params?: Record<string, string>): Promise<ReportRow[]>;
}
export interface ProxyClientOptions {
/** Proxy base (defaults to same-origin ''). The resource route is appended after /proxy. */
base?: string;
/** Injected fetch (tests). Defaults to global fetch. */
fetch?: typeof fetch;
}
// proxyUrl builds a same-origin proxy URL for a resource route + query. Normalizes
// the base (drops a trailing slash) so this is the ONE place base+route joining
// happens. Kept small and pure so tests assert the exact shape
// (…/proxy/workers?limit=…&offset=…).
export function proxyUrl(base: string, route: string, query: Record<string, string> = {}): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') q.set(k, v);
const qs = q.toString();
return `${base.replace(/\/+$/, '')}/proxy${route}${qs ? `?${qs}` : ''}`;
}
// createProxyClient returns a ProxyClient over the same-origin proxy. The cookie
// credentials are attached once here so every method stays a one-liner.
export function createProxyClient(opts: ProxyClientOptions = {}): ProxyClient {
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
const doFetch = opts.fetch ?? fetch;
async function getJson(route: string, query: Record<string, string>): Promise<any> {
const resp = await doFetch(proxyUrl(base, route, query), { credentials: 'include' });
if (!resp.ok) throw new Error(await proxyError(resp));
return resp.json();
}
return {
async listWorkers(page?) {
const data = await getJson('/workers', pageParams(page));
return { items: parseWorkers(data), total: parseTotal(data) };
},
async getWorker(workerId) {
return parseWorker(await getJson(`/workers/${enc(workerId)}`, {}));
},
async listDirectReports(workerId, page?) {
const data = await getJson(`/workers/${enc(workerId)}/directReports`, pageParams(page));
return { items: parseWorkers(data), total: parseTotal(data) };
},
async listOrganizations(page?) {
const data = await getJson('/organizations', pageParams(page));
return { items: parseOrganizations(data), total: parseTotal(data) };
},
async getOrganization(orgId) {
return parseOrganization(await getJson(`/organizations/${enc(orgId)}`, {}));
},
async listRequisitions(page?) {
const data = await getJson('/requisitions', pageParams(page));
return { items: parseJobRequisitions(data), total: parseTotal(data) };
},
async listAbsenceTypes(workerId, page?) {
const data = await getJson(`/workers/${enc(workerId)}/absenceTypes`, pageParams(page));
return { items: parseAbsenceTypes(data), total: parseTotal(data) };
},
async report(owner, name, params = {}) {
return parseReportEntries(await getJson(`/report/${enc(owner)}/${enc(name)}`, params));
},
};
}
// proxyError extracts a readable message from a non-2xx proxy response.
async function proxyError(resp: Response): Promise<string> {
const text = await resp.text().catch(() => '');
try {
const j = JSON.parse(text);
return `Workday proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
} catch {
return `Workday proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
}
}
function enc(segment: number | string): string {
return encodeURIComponent(String(segment));
}
+180
View File
@@ -0,0 +1,180 @@
// People-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
// typed Workday records (workers, organizations, requisitions, absence types,
// time-off report rows) into the plain text an AI action reads, windowed to a
// character budget so a large tenant never overflows the model or gets sent
// silently truncated.
//
// There is ONE windowing contract, identical in spirit to @hanzo/procore's
// project windowing: render ordered blocks, walk them in order, stop at the
// budget, always include at least the first block, and report `truncated`
// honestly. The only HR-specific part is how a record renders to a block.
import type {
Worker,
Organization,
JobRequisition,
AbsenceType,
ReportRow,
} from './workday-api.js';
import { PEOPLE_CHAR_BUDGET } from './config.js';
// ---- Record → text block --------------------------------------------------
// renderWorker turns one worker into a labelled block. Only records-backed facts;
// no compensation or performance is ever rendered (it isn't in the profile). Pure.
export function renderWorker(w: Worker): string {
const lines: string[] = [w.name || w.id];
const head: string[] = [];
if (w.businessTitle) head.push(w.businessTitle);
else if (w.jobTitle) head.push(w.jobTitle);
if (w.organization) head.push(w.organization);
if (head.length) lines.push(head.join(' · '));
const detail: string[] = [];
if (w.jobTitle && w.jobTitle !== w.businessTitle) detail.push(`Job: ${w.jobTitle}`);
if (w.location) detail.push(`Location: ${w.location}`);
if (w.managerName) detail.push(`Manager: ${w.managerName}`);
if (w.email) detail.push(`Email: ${w.email}`);
if (w.isManager) detail.push('People manager');
if (detail.length) lines.push(detail.join(' · '));
return lines.join('\n');
}
// renderOrganization turns one supervisory org into a compact block.
export function renderOrganization(o: Organization): string {
const head = `Organization: ${o.name || o.id}`;
const meta: string[] = [];
if (o.code) meta.push(`Code: ${o.code}`);
if (o.type) meta.push(`Type: ${o.type}`);
if (o.managerName) meta.push(`Manager: ${o.managerName}`);
return meta.length ? `${head}\n${meta.join(' · ')}` : head;
}
// renderJobRequisition turns one requisition into a compact block.
export function renderJobRequisition(r: JobRequisition): string {
const head = `Requisition ${r.name || r.id}: ${r.title}`.trim();
const meta: string[] = [];
if (r.status) meta.push(`Status: ${r.status}`);
if (r.openings) meta.push(`Openings: ${r.openings}`);
if (r.location) meta.push(`Location: ${r.location}`);
if (r.startDate) meta.push(`Start: ${r.startDate}`);
return meta.length ? `${head}\n${meta.join(' · ')}` : head;
}
// renderAbsenceType turns one eligible absence/time-off type into a line.
export function renderAbsenceType(a: AbsenceType): string {
return `Absence type: ${a.name || a.id}`;
}
// renderReportRow turns one RaaS Report_Entry row into a single labelled line of
// column: value pairs, dropping empty cells. Pure.
export function renderReportRow(row: ReportRow): string {
const entries = Object.entries(row).filter(([, v]) => v !== '');
if (entries.length === 0) return '';
return entries.map(([k, v]) => `${k}: ${v}`).join(' · ');
}
// ---- Windowing to a budget ------------------------------------------------
// A named group of rendered blocks — a section of the people context (e.g.
// "Workers", "Direct reports"). The assembler concatenates sections in the order
// given and windows the whole thing to the budget.
export interface Section {
title: string;
blocks: string[];
}
// The windowed people context: the rendered text, how many blocks were available
// vs included, and whether anything was dropped (so the prompt and UI can say so
// honestly).
export interface PeopleContext {
text: string;
totalBlocks: number;
includedBlocks: number;
truncated: boolean;
}
// buildPeopleContext concatenates sections IN ORDER and caps the rendered text at
// `budget` characters. It walks blocks in order (across sections) and stops when
// adding the next block would exceed the budget — always including at least the
// first block (hard-cut to the budget if that one block alone is over).
// `truncated` is true whenever not every available block made it in. Pure and
// total: deterministic, no I/O. This is the ONE windowing algorithm for the
// package — the same "fit ordered text to a budget" contract as @hanzo/procore.
export function buildPeopleContext(
sections: Section[],
budget: number = PEOPLE_CHAR_BUDGET,
): PeopleContext {
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
if (totalBlocks === 0) {
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
}
const parts: string[] = [];
let used = 0;
let included = 0;
let hardCut = false;
outer: for (const section of sections) {
if (section.blocks.length === 0) continue;
// The section header is charged to the first block that fits under it.
let headerPending = `## ${section.title}\n`;
for (const block of section.blocks) {
const prefix = parts.length === 0 ? '' : '\n\n';
const addition = prefix + headerPending + block;
if (used + addition.length > budget) {
if (parts.length === 0) {
// First block alone overflows — hard-cut it to the budget so we always
// send something rather than an empty context.
const solo = (headerPending + block).slice(0, budget);
parts.push(solo);
used = solo.length;
included = 1;
hardCut = true;
}
break outer;
}
parts.push(addition);
used += addition.length;
included += 1;
headerPending = ''; // header only precedes the first block of the section
}
}
const truncated = included < totalBlocks || hardCut;
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
}
// contextNote is the one honest sentence prepended to the people text so the
// model (and, echoed in the panel, the user) knows the scope of what it sees.
// Never claim the whole tenant was sent when it wasn't. Pure.
export function contextNote(ctx: PeopleContext): string {
if (ctx.totalBlocks === 0) return 'No Workday records were available.';
return ctx.truncated
? `Workday records: ${ctx.includedBlocks} of ${ctx.totalBlocks} items (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
: `Workday records: all ${ctx.totalBlocks} item${ctx.totalBlocks === 1 ? '' : 's'}.`;
}
// ---- Convenience section builders -----------------------------------------
//
// These turn a typed record list into a Section, so a caller (server or panel)
// assembles a people context in a couple of lines. Kept here (pure) so the same
// section shapes feed both surfaces and the tests.
export function workerSection(workers: Worker[], title = 'Workers'): Section {
return { title, blocks: workers.map(renderWorker) };
}
export function directReportSection(workers: Worker[], title = 'Direct reports'): Section {
return { title, blocks: workers.map(renderWorker) };
}
export function organizationSection(orgs: Organization[], title = 'Organization'): Section {
return { title, blocks: orgs.map(renderOrganization) };
}
export function requisitionSection(reqs: JobRequisition[], title = 'Job requisitions'): Section {
return { title, blocks: reqs.map(renderJobRequisition) };
}
export function absenceTypeSection(types: AbsenceType[], title = 'Absence types'): Section {
return { title, blocks: types.map(renderAbsenceType) };
}
export function timeOffSection(rows: ReportRow[], title = 'Time off'): Section {
return { title, blocks: rows.map(renderReportRow).filter((b) => b.length > 0) };
}
+291
View File
@@ -0,0 +1,291 @@
// The Workday install + API-proxy service. This is the ONLY place the Workday
// client secret and the OAuth tokens exist — the secret is read from the
// environment (never bundled, never sent to the browser); tokens are minted by the
// OAuth flow and kept server-side, refreshed transparently on expiry. It:
//
// GET /oauth/install → redirect the user to Workday's OAuth consent
// GET /oauth/callback → exchange the code for a token, open a session, and
// hand the browser a session cookie
// GET /proxy/* → forward a browser READ to the Workday REST/RaaS API
// with the session's Bearer token, refreshing the
// token first if it has expired. The browser never
// sees the token, the secret, or the tenant address.
// GET /healthz → readiness
//
// v1 is READ-ONLY: the proxy accepts GET only (405 otherwise). Workday writes are
// heavy, kick off business-process approvals, and mutate the system of record — a
// gated write-back is a deliberate, documented future addition (see README), not
// an accident.
//
// It is a dependency-free Node http handler built on the pure modules (config,
// workday-oauth, workday-api) so it is deployable behind hanzoai/ingress as a
// small service at workday.hanzo.ai. The pure logic it wraps is what the tests
// cover; the http server is an integration concern.
//
// node dist/server.js (after build.js bundles it)
//
// Required environment (see config.readServerConfig):
// WORKDAY_HOST, WORKDAY_TENANT, WORKDAY_CLIENT_ID, WORKDAY_CLIENT_SECRET,
// WORKDAY_REDIRECT_URI, WORKDAY_ENV (sandbox | production), PORT (default 8791)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
import {
authorizeUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
carryRefreshToken,
isExpired,
type WorkdayTokenSet,
} from './workday-oauth.js';
import {
listWorkers,
getWorker,
listDirectReports,
listSupervisoryOrganizations,
getSupervisoryOrganization,
listJobRequisitions,
getJobRequisition,
listEligibleAbsenceTypes,
timeOffReport,
type PreparedGet,
type Page,
type Scope,
} from './workday-api.js';
// A server-side session: the tokens for one authorized user + when they were last
// minted (Workday tokens carry no created_at, so we track the mint wall-clock to
// compute expiry). In-memory here keyed by an opaque session id carried in an
// HttpOnly cookie; a production deployment persists this to hanzoai/kv (Valkey) so
// sessions survive a restart.
interface Session {
token: WorkdayTokenSet;
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
mintedAt: number;
}
const sessions = new Map<string, Session>();
const SESSION_COOKIE = 'workday_sid';
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a
// Set is enough for a single instance.
const pendingStates = new Set<string>();
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function log(fields: Record<string, unknown>): void {
console.log(JSON.stringify(fields));
}
// sessionIdFromCookie reads the session id from the Cookie header. Minimal parser
// — we only need our one cookie.
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
const raw = req.headers.cookie;
if (!raw) return undefined;
for (const part of raw.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
}
return undefined;
}
// GET /oauth/install → 302 to Workday's consent screen. `state` is a fresh CSRF
// token re-checked on callback.
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
const state = randomUUID();
pendingStates.add(state);
const url = authorizeUrl({
host: cfg.workdayHost,
tenant: cfg.workdayTenant,
clientId: cfg.workdayClientId,
redirectUri: cfg.workdayRedirectUri,
scopes: OAUTH_SCOPES,
state,
});
res.writeHead(302, { Location: url });
res.end();
}
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a token
// (secret in the server-side Basic header), open a session, and set the cookie.
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code) return json(res, 400, { error: 'missing code' });
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
const exReq = tokenExchange({
host: cfg.workdayHost,
tenant: cfg.workdayTenant,
clientId: cfg.workdayClientId,
clientSecret: cfg.workdayClientSecret,
code,
redirectUri: cfg.workdayRedirectUri,
});
let token: WorkdayTokenSet;
try {
const resp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
token = parseTokenResponse(await resp.json().catch(() => ({})));
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
const sid = randomUUID();
sessions.set(sid, { token, mintedAt: nowSeconds() });
log({ msg: 'oauth: session opened', sid });
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
});
res.end(JSON.stringify({ ok: true, installed: true }));
}
// freshToken returns a session's access token, refreshing first if it has expired.
// Workday does not rotate the refresh token, so we carry the prior one forward.
// Throws if the refresh fails (the caller returns 401 and the user re-installs).
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
if (!isExpired(session.token, session.mintedAt, nowSeconds())) return session.token.access_token;
const refreshToken = session.token.refresh_token;
if (!refreshToken) throw new Error('session expired and no refresh token');
const rReq = refreshExchange({
host: cfg.workdayHost,
tenant: cfg.workdayTenant,
clientId: cfg.workdayClientId,
clientSecret: cfg.workdayClientSecret,
refreshToken,
});
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
const next = carryRefreshToken(session.token, parseTokenResponse(await resp.json().catch(() => ({}))));
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
log({ msg: 'oauth: token refreshed', sid });
return next.access_token;
}
// pageFromSearch reads limit/offset from a query into a Page (undefined when
// absent/non-positive so pageParams stays clean).
function pageFromSearch(sp: URLSearchParams): Page {
const limit = Number(sp.get('limit'));
const offset = Number(sp.get('offset'));
return {
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
offset: Number.isFinite(offset) && offset > 0 ? offset : undefined,
};
}
// searchRecord collects report prompt params (everything but the format we force).
function searchRecord(sp: URLSearchParams): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of sp) if (k !== 'format') out[k] = v;
return out;
}
// resolveTarget maps a semantic proxy route (segments after /proxy) to the Workday
// request the api layer builds. Returns null for an unknown resource (→ 404). This
// is the single place the browser's resource vocabulary maps onto Workday's URLs,
// keeping the tenant address server-side.
function resolveTarget(scope: Scope, segments: string[], sp: URLSearchParams): PreparedGet | null {
const [a, b, c] = segments;
if (a === 'workers') {
if (!b) return listWorkers(scope, pageFromSearch(sp));
if (!c) return getWorker(scope, b);
if (c === 'directReports') return listDirectReports(scope, b, pageFromSearch(sp));
if (c === 'absenceTypes') return listEligibleAbsenceTypes(scope, b, pageFromSearch(sp));
return null;
}
if (a === 'organizations') {
if (!b) return listSupervisoryOrganizations(scope, pageFromSearch(sp));
if (!c) return getSupervisoryOrganization(scope, b);
return null;
}
if (a === 'requisitions') {
if (!b) return listJobRequisitions(scope, pageFromSearch(sp));
if (!c) return getJobRequisition(scope, b);
return null;
}
if (a === 'report' && b && c) return timeOffReport(scope, b, c, searchRecord(sp));
return null;
}
// GET /proxy/* → forward a READ to Workday. The browser sends a resource route
// (/proxy/workers, /proxy/organizations/{id}, /proxy/report/{owner}/{name}, …);
// the server resolves it to a REST/RaaS URL, injects the Bearer token (never
// exposed to the browser), fetches, and returns the upstream JSON verbatim (the
// panel parses it). GET only — v1 is read-only.
async function handleProxy(
cfg: ServerConfig,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
if (req.method !== 'GET') {
return json(res, 405, { error: 'read-only: v1 proxy supports GET reads only' });
}
const sid = sessionIdFromCookie(req);
const session = sid ? sessions.get(sid) : undefined;
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
let accessToken: string;
try {
accessToken = await freshToken(cfg, sid, session);
} catch (e: any) {
return json(res, 401, { error: e?.message || 'token refresh failed' });
}
const scope: Scope = { host: cfg.workdayHost, tenant: cfg.workdayTenant, accessToken };
const rest = url.pathname.replace(/^\/proxy/, '');
const segments = rest.split('/').filter(Boolean).map(decodeURIComponent);
const target = resolveTarget(scope, segments, url.searchParams);
if (!target) return json(res, 404, { error: 'unknown resource' });
try {
const upstream = await fetch(target.url, { headers: target.headers });
const text = await upstream.text();
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
res.end(text);
} catch (e: any) {
log({ msg: 'proxy: upstream error', error: e?.message, url: target.url });
return json(res, 502, { error: 'upstream request failed' });
}
}
// createHandler is the request router. Everything is a small handler over the pure
// modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/')) return await handleProxy(cfg, req, res, url);
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
return json(res, 404, { error: 'not found' });
} catch (e: any) {
log({ msg: 'unhandled error', error: e?.message });
return json(res, 500, { error: 'internal error' });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo workday service up', port: cfg.port, env: cfg.workdayEnv, tenant: cfg.workdayTenant });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+50
View File
@@ -0,0 +1,50 @@
:root {
color-scheme: light dark;
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
--ok: #3fb950; --warn: #d29922; --error: #f85149;
}
}
* { box-sizing: border-box; }
body {
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
max-width: 720px;
}
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header .title { font-weight: 600; font-size: 15px; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
textarea, select, input {
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
}
textarea#prompt { min-height: 56px; resize: vertical; }
textarea#output { min-height: 220px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.row.scope { margin-top: 4px; }
.row.scope select#worker { flex: 1; }
button {
font: inherit; border: 1px solid var(--line); background: var(--accent);
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.spacer { flex: 1; }
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip {
padding: 5px 10px; border-radius: 999px; font-size: 13px;
background: transparent; color: var(--fg); border: 1px solid var(--line);
}
.chip:hover:not(:disabled) { border-color: var(--accent); }
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
+353
View File
@@ -0,0 +1,353 @@
// Workday REST + RaaS — pure request shaping + response parsing. Every function
// returns a PreparedGet (url + headers) or parses a response body; none opens a
// socket, so the whole API surface is unit-testable without a network. server.ts
// is the thin glue that fetches these shapes with the OAuth Bearer access token.
//
// Two read surfaces, one bearer:
// • REST — `${host}/ccx/api/{service}/{version}/{tenant}/…` (Staffing, Recruiting,
// Absence). List endpoints paginate with `limit` + `offset` and return a
// `{ total, data: [...] }` envelope (parsed by parseTotal / parseRestData).
// • RaaS — `${host}/ccx/service/customreport2/{tenant}/{owner}/{report}?format=json`,
// a customer-defined report returning `{ Report_Entry: [...] }` rows.
// Docs: developer.workday.com (REST API reference) + Workday RaaS.
//
// v1 is READ-ONLY: Workday writes (e.g. requestTimeOff, business-process
// submissions) are heavy, kick off approval workflows, and are the system of
// record — so this module exposes reads only. A gated write-back is a documented,
// deliberate future addition, not an oversight (see README).
import {
restUrl,
raasUrl,
STAFFING,
RECRUITING,
ABSENCE,
type WorkdayService,
} from './config.js';
// A prepared GET: url + headers a single fetch needs. Bearer + JSON accept.
export interface PreparedGet {
url: string;
headers: Record<string, string>;
}
// The scope every request carries: which tenant (host + name) and the bearer.
// Grouping them keeps every wrapper's signature small and the scoping impossible
// to forget.
export interface Scope {
host: string;
tenant: string;
accessToken: string;
}
// headers builds the auth + accept header set common to every request.
function headers(accessToken: string): Record<string, string> {
return { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' };
}
// ---- Pagination -----------------------------------------------------------
// Workday REST paginates with `limit` (page size) + `offset` (0-based row index)
// and returns the total in the body. MAX_LIMIT is Workday's documented ceiling.
export const MAX_LIMIT = 100;
export const DEFAULT_LIMIT = 100;
export interface Page {
limit?: number;
offset?: number;
}
// pageParams renders pagination into query pairs, clamping limit to the
// documented maximum and dropping a zero/absent offset (0 is the default). Only
// emits params that are set, so an unpaginated call stays clean. Pure.
export function pageParams(page: Page | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!page) return out;
if (typeof page.limit === 'number' && page.limit > 0) {
out.limit = String(Math.min(Math.floor(page.limit), MAX_LIMIT));
}
if (typeof page.offset === 'number' && page.offset > 0) {
out.offset = String(Math.floor(page.offset));
}
return out;
}
// withQuery appends a query string to a full URL, dropping undefined/empty
// values. One place appends every query so shaping stays consistent. Pure.
function withQuery(url: string, query: Record<string, string> = {}): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== '') q.set(k, v);
}
const qs = q.toString();
return qs ? `${url}?${qs}` : url;
}
// enc encodes a single path segment (Workday IDs are opaque WID hex strings, but
// we encode defensively so a stray value can never break — or traverse — the path).
function enc(segment: number | string): string {
return encodeURIComponent(String(segment));
}
// ---- REST + RaaS request builders -----------------------------------------
// restGet shapes a GET against a REST service + sub-path with the Bearer header.
export function restGet(
scope: Scope,
service: WorkdayService,
sub: string,
query: Record<string, string> = {},
): PreparedGet {
return {
url: withQuery(restUrl(scope.host, scope.tenant, service, sub), query),
headers: headers(scope.accessToken),
};
}
// raasGet shapes a GET against a custom report, forcing `format=json` so we always
// parse the Report_Entry JSON shape (never the default XML). Extra report prompt
// params (e.g. an as-of date) merge in.
export function raasGet(
scope: Scope,
owner: string,
report: string,
query: Record<string, string> = {},
): PreparedGet {
return {
url: withQuery(raasUrl(scope.host, scope.tenant, owner, report), { format: 'json', ...query }),
headers: headers(scope.accessToken),
};
}
// ---- Workers (Staffing) ---------------------------------------------------
// listWorkers — the workers in the tenant the client can see. The panel's picker
// uses this to choose which worker to run against.
export function listWorkers(scope: Scope, page?: Page): PreparedGet {
return restGet(scope, STAFFING, '/workers', pageParams(page));
}
// getWorker — one worker's full profile (name, business title, org, job, manager).
// This is what summarize-worker / draft-review run over.
export function getWorker(scope: Scope, workerId: number | string): PreparedGet {
return restGet(scope, STAFFING, `/workers/${enc(workerId)}`);
}
// listDirectReports — the workers who report to a given worker (team roster).
export function listDirectReports(scope: Scope, workerId: number | string, page?: Page): PreparedGet {
return restGet(scope, STAFFING, `/workers/${enc(workerId)}/directReports`, pageParams(page));
}
// ---- Organizations (Staffing) ---------------------------------------------
// listSupervisoryOrganizations — the supervisory orgs (the manager→team tree).
export function listSupervisoryOrganizations(scope: Scope, page?: Page): PreparedGet {
return restGet(scope, STAFFING, '/supervisoryOrganizations', pageParams(page));
}
// getSupervisoryOrganization — one org in full (manager, code, type).
export function getSupervisoryOrganization(scope: Scope, orgId: number | string): PreparedGet {
return restGet(scope, STAFFING, `/supervisoryOrganizations/${enc(orgId)}`);
}
// ---- Job requisitions (Recruiting) ----------------------------------------
// listJobRequisitions — the open/closed requisitions (headcount demand).
export function listJobRequisitions(scope: Scope, page?: Page): PreparedGet {
return restGet(scope, RECRUITING, '/jobRequisitions', pageParams(page));
}
// getJobRequisition — one requisition in full.
export function getJobRequisition(scope: Scope, reqId: number | string): PreparedGet {
return restGet(scope, RECRUITING, `/jobRequisitions/${enc(reqId)}`);
}
// ---- Time off / absence (Absence Management) ------------------------------
// listEligibleAbsenceTypes — the absence/time-off types a worker is eligible for
// (the REST view of time off). Richer time-off history is typically pulled via a
// RaaS report (timeOffReport) since it is customer-configured.
export function listEligibleAbsenceTypes(
scope: Scope,
workerId: number | string,
page?: Page,
): PreparedGet {
return restGet(scope, ABSENCE, `/workers/${enc(workerId)}/eligibleAbsenceTypes`, pageParams(page));
}
// timeOffReport — a customer-defined time-off report via RaaS. `owner` is the
// report owner's username, `report` the report name; extra prompt params pass
// through (e.g. an as-of date). Returns Report_Entry rows (parseReportEntries).
export function timeOffReport(
scope: Scope,
owner: string,
report: string,
params: Record<string, string> = {},
): PreparedGet {
return raasGet(scope, owner, report, params);
}
// ---- Response parsing -----------------------------------------------------
//
// Workday REST responses use `descriptor` for the human label and `id` for the
// WID, with nested references as `{ id, descriptor }`. We normalize the fields we
// surface into small typed shapes at this one boundary so nothing downstream
// touches the wire.
// ref renders a Workday reference (or bare string) to its display label.
function ref(x: any): string {
if (!x) return '';
if (typeof x === 'string') return x;
return String(x.descriptor ?? x.name ?? '');
}
// parseTotal reads the `total` count Workday returns in a list envelope body.
// Returns 0 for a missing/unparseable value. Pure.
export function parseTotal(data: any): number {
const n = Number(data?.total);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
// parseRestData reads the `data` array from a `{ total, data }` envelope, also
// tolerating a bare array. Pure.
export function parseRestData(data: any): any[] {
return Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
}
// One worker as surfaced from getWorker / listWorkers.
export interface Worker {
id: string;
name: string;
businessTitle: string;
email: string;
isManager: boolean;
jobTitle: string;
organization: string;
location: string;
managerName: string;
}
export function parseWorker(d: any): Worker {
const primaryJob = d?.primaryJob ?? {};
return {
id: String(d?.id ?? ''),
name: ref(d),
businessTitle: String(d?.businessTitle ?? ''),
email: String(d?.primaryWorkEmail ?? ''),
isManager: d?.isManager === true,
jobTitle: ref(primaryJob?.jobProfile) || ref(primaryJob),
organization: ref(d?.primarySupervisoryOrganization) || ref(d?.supervisoryOrganization),
location: ref(primaryJob?.location) || ref(d?.location),
managerName: ref(primaryJob?.manager) || ref(d?.manager),
};
}
export function parseWorkers(data: any): Worker[] {
return parseRestData(data)
.map(parseWorker)
.filter((w: Worker) => w.id.length > 0);
}
// One supervisory organization.
export interface Organization {
id: string;
name: string;
code: string;
type: string;
managerName: string;
}
export function parseOrganization(d: any): Organization {
return {
id: String(d?.id ?? ''),
name: ref(d),
code: String(d?.code ?? d?.organizationCode ?? d?.referenceID ?? ''),
type: ref(d?.organizationType) || String(d?.type ?? ''),
managerName: ref(d?.manager),
};
}
export function parseOrganizations(data: any): Organization[] {
return parseRestData(data)
.map(parseOrganization)
.filter((o: Organization) => o.id.length > 0);
}
// One job requisition.
export interface JobRequisition {
id: string;
name: string;
title: string;
status: string;
openings: number;
location: string;
startDate: string;
}
export function parseJobRequisition(d: any): JobRequisition {
return {
id: String(d?.id ?? ''),
name: ref(d),
title: String(d?.jobPostingTitle ?? d?.title ?? ''),
status: ref(d?.jobRequisitionStatus) || String(d?.status ?? ''),
openings: Number(d?.numberOfOpenings ?? 0) || 0,
location: ref(d?.primaryLocation) || ref(d?.location),
startDate: String(d?.recruitingStartDate ?? ''),
};
}
export function parseJobRequisitions(data: any): JobRequisition[] {
return parseRestData(data)
.map(parseJobRequisition)
.filter((r: JobRequisition) => r.id.length > 0);
}
// One eligible absence/time-off type.
export interface AbsenceType {
id: string;
name: string;
}
export function parseAbsenceTypes(data: any): AbsenceType[] {
return parseRestData(data)
.map((d: any) => ({ id: String(d?.id ?? ''), name: ref(d) }))
.filter((a: AbsenceType) => a.name.length > 0 || a.id.length > 0);
}
// ---- RaaS Report_Entry ----------------------------------------------------
// A report row: a flat map of column label → string value. RaaS columns are
// report-defined, so rows are generic rather than typed.
export type ReportRow = Record<string, string>;
// stringifyCell renders one RaaS cell to a string, taking the Descriptor/ID off a
// nested reference object and joining arrays. Pure.
function stringifyCell(v: any): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) return v.map(stringifyCell).filter((s) => s.length > 0).join(', ');
if (typeof v === 'object') {
return String(v.Descriptor ?? v.descriptor ?? v.ID ?? v.id ?? v.value ?? JSON.stringify(v));
}
return String(v);
}
// flattenEntry turns one Report_Entry object into a flat ReportRow. Pure.
export function flattenEntry(row: any): ReportRow {
const out: ReportRow = {};
if (!row || typeof row !== 'object') return out;
for (const [k, v] of Object.entries(row)) out[k] = stringifyCell(v);
return out;
}
// parseReportEntries reads a RaaS `{ Report_Entry: [...] }` body (or a bare array)
// into flat rows. Pure.
export function parseReportEntries(data: any): ReportRow[] {
const rows = Array.isArray(data?.Report_Entry)
? data.Report_Entry
: Array.isArray(data)
? data
: [];
return rows.map(flattenEntry);
}
+172
View File
@@ -0,0 +1,172 @@
// Workday OAuth 2.0 (Authorization Code Grant) — pure request shaping. The client
// secret is held by the server (server.ts) and never reaches the browser; these
// functions build the exact URL/body/headers of each OAuth call so the wire shape
// is unit-testable without a network round-trip. The token exchange and refresh
// are each one fetch in the server.
//
// Workday differs from Procore in two ways that matter, and both are modelled
// here rather than glossed over:
// 1. The token endpoint authenticates the client with HTTP Basic
// (Authorization: Basic base64(client_id:client_secret)) — the secret rides
// in the header, NOT the form body. developer.workday.com's OAuth guide.
// 2. Workday does NOT rotate the refresh token on refresh and the refresh
// response usually omits refresh_token — so the caller carries the prior one
// forward (carryRefreshToken).
//
// Endpoints hang off the tenant OAuth root `${host}/ccx/oauth2/{tenant}`:
// authorize → …/authorize token → …/token
import { oauthBaseUrl } from './config.js';
// basicAuthHeader builds the `Authorization: Basic …` value Workday's token
// endpoint requires for client authentication. Server-side only (it encodes the
// secret). Pure — unit-tested. Uses Node's Buffer where available (the server and
// tests), falling back to btoa for completeness.
export function basicAuthHeader(clientId: string, clientSecret: string): string {
const raw = `${clientId}:${clientSecret}`;
const b64 =
typeof Buffer !== 'undefined' ? Buffer.from(raw, 'utf8').toString('base64') : btoa(raw);
return `Basic ${b64}`;
}
// authorizeUrl is where the install flow sends the user's browser to grant the
// API Client. `state` is the CSRF token the server generates and re-checks on
// callback. `scope` is only appended when non-empty (a standard client is scoped
// by its registered functional areas in the tenant).
export function authorizeUrl(args: {
host: string;
tenant: string;
clientId: string;
redirectUri: string;
scopes?: readonly string[];
state: string;
}): string {
const params: Record<string, string> = {
response_type: 'code',
client_id: args.clientId,
redirect_uri: args.redirectUri,
state: args.state,
};
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
const q = new URLSearchParams(params);
return `${oauthBaseUrl(args.host, args.tenant)}/authorize?${q.toString()}`;
}
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a test
// asserts the header + body without opening a socket. Workday's token endpoint
// takes application/x-www-form-urlencoded with Basic client auth.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// tokenUrl is the OAuth token endpoint for a tenant.
export function tokenUrl(host: string, tenant: string): string {
return `${oauthBaseUrl(host, tenant)}/token`;
}
// tokenExchange builds the code→token request against the tenant's /token. The
// grant params (code + the SAME redirect_uri used on /authorize, which Workday
// requires to match) ride in the form body; the client credentials ride in the
// Basic Authorization header, so the secret never appears in the body.
export function tokenExchange(args: {
host: string;
tenant: string;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): PreparedRequest {
return {
url: tokenUrl(args.host, args.tenant),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: basicAuthHeader(args.clientId, args.clientSecret),
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
redirect_uri: args.redirectUri,
}).toString(),
};
}
// refreshExchange builds the refresh-token→token request. Workday access tokens
// are short-lived (~1h) and the refresh token is long-lived and non-rotating, so
// the response usually returns only a new access_token (see carryRefreshToken).
export function refreshExchange(args: {
host: string;
tenant: string;
clientId: string;
clientSecret: string;
refreshToken: string;
}): PreparedRequest {
return {
url: tokenUrl(args.host, args.tenant),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: basicAuthHeader(args.clientId, args.clientSecret),
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
}).toString(),
};
}
// The token response we care about. Workday returns access_token (+ refresh_token
// on the initial exchange, expires_in, token_type). No created_at — the caller
// records the mint time. parseTokenResponse validates the one required field and
// surfaces Workday's own error otherwise.
export interface WorkdayTokenSet {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
}
export function parseTokenResponse(data: any): WorkdayTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error) {
const reason = data.error_description || data.message || data.error;
throw new Error(`Workday OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('Workday OAuth response missing access_token');
}
return {
access_token: data.access_token,
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
};
}
// carryRefreshToken keeps the prior refresh token when a refresh response omits
// one (Workday does not rotate it). Returns `next` unchanged when it already
// carries a refresh token. Pure — the ONE place refresh-token continuity lives.
export function carryRefreshToken(prev: WorkdayTokenSet, next: WorkdayTokenSet): WorkdayTokenSet {
return next.refresh_token ? next : { ...next, refresh_token: prev.refresh_token };
}
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set and
// the epoch-seconds mint time, applying a safety skew so a request is never sent
// with a token about to expire mid-flight. Workday tokens carry no created_at, so
// the caller (server) supplies mintedAt. Pure.
export function tokenExpiresAt(token: WorkdayTokenSet, mintedAt: number, skewSeconds = 60): number {
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
return mintedAt + ttl - skewSeconds;
}
// isExpired reports whether a token set is at/past its (skew-adjusted) expiry as
// of `now`, given when it was minted. The server checks this before each API call
// and refreshes when true. Pure.
export function isExpired(
token: WorkdayTokenSet,
mintedAt: number,
now: number,
skewSeconds = 60,
): boolean {
return now >= tokenExpiresAt(token, mintedAt, skewSeconds);
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
import { buildPeopleContext } from '../src/people.js';
const ctx = buildPeopleContext([{ title: 'Workers', blocks: ['Logan McNeil — VP, HR'] }]);
function mockClient(text: string) {
const reply: ChatCompletion = {
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
};
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
}
describe('actions: catalog', () => {
it('exposes exactly the five documented actions', () => {
expect(Object.keys(ACTIONS).sort()).toEqual(
['answerHrQuestion', 'draftJobDescription', 'draftReviewFeedback', 'summarizeOrg', 'summarizeWorker'].sort(),
);
});
it('every action has a non-empty label and prompt', () => {
for (const [id, a] of Object.entries(ACTIONS)) {
expect(a.label.length, id).toBeGreaterThan(0);
expect(a.prompt.length, id).toBeGreaterThan(20);
}
});
it('actionList mirrors the catalog order and shape', () => {
const list = actionList();
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
expect(list[0]).toEqual({ id: 'summarizeWorker', label: ACTIONS.summarizeWorker.label });
});
});
describe('actions: prompt intent + HR guardrails', () => {
it('summarizeWorker forbids inferring compensation or protected characteristics', () => {
const p = actionPrompt('summarizeWorker');
expect(p).toMatch(/compensation/i);
expect(p).toMatch(/protected characteristic/i);
});
it('draftJobDescription asks for inclusive, bias-free language', () => {
const p = actionPrompt('draftJobDescription');
expect(p).toMatch(/inclusive|bias-free/i);
expect(p).toMatch(/requisition/i);
});
it('summarizeOrg asks for headcount and open requisitions', () => {
const p = actionPrompt('summarizeOrg');
expect(p).toMatch(/headcount/i);
expect(p).toMatch(/requisition/i);
});
it('answerHrQuestion says the answer is informational and may need review', () => {
expect(actionPrompt('answerHrQuestion')).toMatch(/informational/i);
expect(actionPrompt('answerHrQuestion')).toMatch(/review/i);
});
it('draftReviewFeedback notes the records lack performance ratings', () => {
expect(actionPrompt('draftReviewFeedback')).toMatch(/performance rating/i);
});
});
describe('actions: isActionId / actionPrompt guards', () => {
it('narrows known ids and rejects unknown', () => {
expect(isActionId('summarizeWorker')).toBe(true);
expect(isActionId('nope')).toBe(false);
});
it('actionPrompt throws on an unknown id', () => {
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
});
});
describe('actions: runAction', () => {
it('routes the resolved prompt through ask (one code path to the model)', async () => {
const { client, create } = mockClient('summary');
const out = await runAction('summarizeWorker', ctx, { subject: 'Logan McNeil' }, { client });
expect(out).toBe('summary');
const user = String(create.mock.calls[0][0].messages[1].content);
expect(user).toContain(ACTIONS.summarizeWorker.prompt);
expect(user).toContain('Logan McNeil');
});
it('rejects (not synchronously throws) on an unknown id', async () => {
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest';
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
APIKEY_STORAGE_KEY,
PEOPLE_CHAR_BUDGET,
pickBearer,
chatCompletionsURL,
modelsURL,
normHost,
oauthBaseUrl,
restPath,
restUrl,
raasPath,
raasUrl,
STAFFING,
RECRUITING,
ABSENCE,
COMMON,
isWorkdayEnv,
isSandboxHost,
readServerConfig,
} from '../src/config.js';
const HOST = 'https://wd2-impl-services1.workday.com';
const TENANT = 'acme';
describe('config: gateway constants', () => {
it('points at api.hanzo.ai with /v1 paths and never /api/', () => {
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
});
it('defaults to a zen model and a sane budget', () => {
expect(DEFAULT_MODEL).toBe('zen5');
expect(PEOPLE_CHAR_BUDGET).toBeGreaterThan(10_000);
expect(APIKEY_STORAGE_KEY).toContain('workday');
});
});
describe('config: pickBearer', () => {
it('prefers a pasted key over an oauth token', () => {
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
});
it('falls back to the oauth token, trimming whitespace', () => {
expect(pickBearer(' ', ' tok ')).toBe('tok');
});
it('returns empty when both are blank (anonymous)', () => {
expect(pickBearer('', '')).toBe('');
expect(pickBearer(' ', '')).toBe('');
});
});
describe('config: normHost', () => {
it('strips one or many trailing slashes', () => {
expect(normHost('https://x.workday.com/')).toBe('https://x.workday.com');
expect(normHost('https://x.workday.com///')).toBe('https://x.workday.com');
expect(normHost('https://x.workday.com')).toBe('https://x.workday.com');
});
});
describe('config: service descriptors', () => {
it('pins each Workday REST service + version', () => {
expect(STAFFING).toEqual({ api: 'staffing', version: 'v6' });
expect(RECRUITING).toEqual({ api: 'recruiting', version: 'v4' });
expect(ABSENCE).toEqual({ api: 'absenceManagement', version: 'v1' });
expect(COMMON).toEqual({ api: 'common', version: 'v1' });
});
});
describe('config: OAuth + REST + RaaS URL builders', () => {
it('builds the tenant OAuth root', () => {
expect(oauthBaseUrl(HOST, TENANT)).toBe(
'https://wd2-impl-services1.workday.com/ccx/oauth2/acme',
);
});
it('normalizes a trailing slash on the host in every builder', () => {
expect(oauthBaseUrl(HOST + '/', TENANT)).toBe(oauthBaseUrl(HOST, TENANT));
expect(restUrl(HOST + '/', TENANT, STAFFING, '/workers')).toBe(
restUrl(HOST, TENANT, STAFFING, '/workers'),
);
});
it('builds a host-relative REST path with service + version + tenant', () => {
expect(restPath(TENANT, STAFFING, '/workers')).toBe('/ccx/api/staffing/v6/acme/workers');
expect(restPath(TENANT, RECRUITING, '/jobRequisitions')).toBe(
'/ccx/api/recruiting/v4/acme/jobRequisitions',
);
expect(restPath(TENANT, ABSENCE)).toBe('/ccx/api/absenceManagement/v1/acme');
});
it('builds an absolute REST url', () => {
expect(restUrl(HOST, TENANT, STAFFING, '/workers/123')).toBe(
'https://wd2-impl-services1.workday.com/ccx/api/staffing/v6/acme/workers/123',
);
});
it('builds the RaaS custom-report path + url', () => {
expect(raasPath(TENANT, 'lmcneil', 'Team_Time_Off')).toBe(
'/ccx/service/customreport2/acme/lmcneil/Team_Time_Off',
);
expect(raasUrl(HOST, TENANT, 'lmcneil', 'Team_Time_Off')).toBe(
'https://wd2-impl-services1.workday.com/ccx/service/customreport2/acme/lmcneil/Team_Time_Off',
);
});
});
describe('config: isWorkdayEnv', () => {
it('accepts the two environments', () => {
expect(isWorkdayEnv('production')).toBe(true);
expect(isWorkdayEnv('sandbox')).toBe(true);
});
it('rejects anything else', () => {
expect(isWorkdayEnv('impl')).toBe(false);
expect(isWorkdayEnv(undefined)).toBe(false);
});
});
describe('config: isSandboxHost', () => {
it('detects an implementation host by -impl', () => {
expect(isSandboxHost('https://wd2-impl-services1.workday.com')).toBe(true);
expect(isSandboxHost('https://impl.workday.com')).toBe(false);
expect(isSandboxHost('https://services1.myworkday.com')).toBe(false);
});
});
describe('config: readServerConfig', () => {
const base = {
WORKDAY_HOST: HOST,
WORKDAY_TENANT: TENANT,
WORKDAY_CLIENT_ID: 'cid',
WORKDAY_CLIENT_SECRET: 'secret',
WORKDAY_REDIRECT_URI: 'https://workday.hanzo.ai/oauth/callback',
};
it('reads a full config, defaulting env to sandbox and port to 8791', () => {
const cfg = readServerConfig(base);
expect(cfg.workdayHost).toBe(HOST);
expect(cfg.workdayTenant).toBe('acme');
expect(cfg.workdayClientId).toBe('cid');
expect(cfg.workdayClientSecret).toBe('secret');
expect(cfg.workdayRedirectUri).toBe('https://workday.hanzo.ai/oauth/callback');
expect(cfg.workdayEnv).toBe('sandbox');
expect(cfg.port).toBe(8791);
});
it('normalizes a trailing slash on the host', () => {
const cfg = readServerConfig({ ...base, WORKDAY_HOST: HOST + '/' });
expect(cfg.workdayHost).toBe(HOST);
});
it('honours WORKDAY_ENV and PORT', () => {
const cfg = readServerConfig({ ...base, WORKDAY_ENV: 'production', PORT: '9002' });
expect(cfg.workdayEnv).toBe('production');
expect(cfg.port).toBe(9002);
});
it('coerces an unknown WORKDAY_ENV to sandbox', () => {
expect(readServerConfig({ ...base, WORKDAY_ENV: 'staging' }).workdayEnv).toBe('sandbox');
});
it('throws listing every missing required value', () => {
expect(() => readServerConfig({})).toThrow(/WORKDAY_HOST/);
expect(() => readServerConfig({})).toThrow(/WORKDAY_TENANT/);
expect(() => readServerConfig({})).toThrow(/WORKDAY_CLIENT_ID/);
expect(() => readServerConfig({})).toThrow(/WORKDAY_CLIENT_SECRET/);
expect(() => readServerConfig({})).toThrow(/WORKDAY_REDIRECT_URI/);
expect(() => readServerConfig({ WORKDAY_HOST: HOST })).toThrow(
/WORKDAY_TENANT, WORKDAY_CLIENT_ID, WORKDAY_CLIENT_SECRET, WORKDAY_REDIRECT_URI/,
);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
import {
SYSTEM_PROMPT,
buildMessages,
extractContent,
ask,
listModels,
type PeopleMeta,
} from '../src/hanzo.js';
import { buildPeopleContext, type PeopleContext } from '../src/people.js';
const ctx: PeopleContext = buildPeopleContext([{ title: 'Workers', blocks: ['Logan McNeil — VP, HR'] }]);
const meta: PeopleMeta = { tenant: 'acme', organization: 'Human Resources', subject: 'Logan McNeil' };
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
return {
id: 'c1',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
};
}
// mockClient captures the params the code sends and returns a canned completion.
function mockClient(reply: ChatCompletion, models: Model[] = []) {
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
return { client, create, list };
}
describe('hanzo: buildMessages', () => {
it('produces a grounded system prompt + a fenced user turn with meta + context note', () => {
const msgs = buildMessages('Who manages this team?', ctx, meta);
expect(msgs).toHaveLength(2);
expect(msgs[0].role).toBe('system');
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
const user = String(msgs[1].content);
expect(user).toContain('Subject: Logan McNeil');
expect(user).toContain('Organization: Human Resources');
expect(user).toContain('Tenant: acme');
expect(user).toContain('---- workday records ----');
expect(user).toContain('Logan McNeil — VP, HR');
expect(user).toContain('---- task ----');
expect(user).toContain('Who manages this team?');
});
it('omits the header block entirely when meta is undefined', () => {
const user = String(buildMessages('q', ctx).at(1)!.content);
expect(user).not.toContain('Subject:');
expect(user).not.toContain('Tenant:');
expect(user).toContain('---- task ----');
});
it('SYSTEM_PROMPT forbids inventing records, guards PII, and disclaims decisions', () => {
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
expect(SYSTEM_PROMPT).toMatch(/protected characteristics/i);
expect(SYSTEM_PROMPT).toMatch(/not.*(employment|disciplinary).*decisions/i);
});
});
describe('hanzo: extractContent', () => {
it('reads a plain string content', () => {
expect(extractContent(completion('hello'))).toBe('hello');
});
it('concatenates text parts of an array content', () => {
const parts = [
{ type: 'text', text: 'a' },
{ type: 'image_url', image_url: { url: 'x' } },
{ type: 'text', text: 'b' },
] as any;
expect(extractContent(completion(parts))).toBe('ab');
});
it('throws on empty content', () => {
expect(() => extractContent(completion(''))).toThrow(/no content/);
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
});
});
describe('hanzo: ask', () => {
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
const { client, create } = mockClient(completion('Joy Banks manages HR.'));
const out = await ask('Who manages this team?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
expect(out).toBe('Joy Banks manages HR.');
expect(create).toHaveBeenCalledOnce();
const [params, options] = create.mock.calls[0];
expect(params.model).toBe('zen5');
expect(params.stream).toBe(false);
expect(params.temperature).toBe(0.2);
expect(params.messages[0].role).toBe('system');
expect(options).toBeDefined();
});
it('omits temperature from the wire when not provided', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect('temperature' in create.mock.calls[0][0]).toBe(false);
});
it('defaults the model to zen5', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect(create.mock.calls[0][0].model).toBe('zen5');
});
it('propagates an abort signal to the client', async () => {
const { client, create } = mockClient(completion('ok'));
const controller = new AbortController();
await ask('q', ctx, undefined, { client, signal: controller.signal });
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('hanzo: listModels', () => {
it('maps the model catalog to ids', async () => {
const models: Model[] = [
{ id: 'zen5', object: 'model' },
{ id: 'zen5-pro', object: 'model' },
];
const { client } = mockClient(completion('x'), models);
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest';
import {
basicAuthHeader,
authorizeUrl,
tokenUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
carryRefreshToken,
tokenExpiresAt,
isExpired,
} from '../src/workday-oauth.js';
const HOST = 'https://wd2-impl-services1.workday.com';
const TENANT = 'acme';
describe('oauth: basicAuthHeader', () => {
it('base64-encodes client_id:client_secret as HTTP Basic', () => {
const h = basicAuthHeader('cid', 'sec');
expect(h).toBe(`Basic ${Buffer.from('cid:sec').toString('base64')}`);
// Decodes back to the pair.
const decoded = Buffer.from(h.replace('Basic ', ''), 'base64').toString('utf8');
expect(decoded).toBe('cid:sec');
});
it('never leaks the raw secret in the header value', () => {
expect(basicAuthHeader('cid', 'SUPERSECRET')).not.toContain('SUPERSECRET');
});
});
describe('oauth: authorizeUrl', () => {
it('builds the tenant consent URL with the standard params', () => {
const url = new URL(
authorizeUrl({
host: HOST,
tenant: TENANT,
clientId: 'cid',
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
state: 'st-1',
}),
);
expect(url.origin).toBe('https://wd2-impl-services1.workday.com');
expect(url.pathname).toBe('/ccx/oauth2/acme/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('cid');
expect(url.searchParams.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
expect(url.searchParams.get('state')).toBe('st-1');
});
it('omits scope when empty', () => {
const url = new URL(
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
);
expect(url.searchParams.has('scope')).toBe(false);
});
it('space-joins scopes when present', () => {
const url = new URL(
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['Staffing', 'Time_Off'] }),
);
expect(url.searchParams.get('scope')).toBe('Staffing Time_Off');
});
});
describe('oauth: tokenUrl', () => {
it('resolves the token endpoint on the tenant OAuth root', () => {
expect(tokenUrl(HOST, TENANT)).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
});
});
describe('oauth: tokenExchange', () => {
it('puts the client credentials in the Basic header, NOT the body', () => {
const req = tokenExchange({
host: HOST,
tenant: TENANT,
clientId: 'cid',
clientSecret: 'sec',
code: 'the-code',
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
});
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('the-code');
expect(body.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
// Secret + id are NOT in the body (they ride in the Basic header).
expect(body.has('client_secret')).toBe(false);
expect(body.has('client_id')).toBe(false);
});
it('never puts the secret in the body string', () => {
const req = tokenExchange({ host: HOST, tenant: TENANT, clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
expect(req.body).not.toContain('SUPER');
});
});
describe('oauth: refreshExchange', () => {
it('form-encodes the refresh grant with Basic client auth', () => {
const req = refreshExchange({ host: HOST, tenant: TENANT, clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('r-1');
expect(body.has('code')).toBe(false);
expect(body.has('client_secret')).toBe(false);
});
});
describe('oauth: parseTokenResponse', () => {
it('parses a full token set (no created_at on Workday)', () => {
const t = parseTokenResponse({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
token_type: 'Bearer',
});
expect(t.access_token).toBe('at');
expect(t.refresh_token).toBe('rt');
expect(t.expires_in).toBe(3600);
expect(t.token_type).toBe('Bearer');
});
it('throws on a Workday error payload with the description', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
});
it('throws when access_token is missing or empty', () => {
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
});
it('drops non-string/non-number optional fields defensively', () => {
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
expect(t.refresh_token).toBeUndefined();
expect(t.expires_in).toBeUndefined();
});
});
describe('oauth: carryRefreshToken (Workday does not rotate)', () => {
it('keeps the prior refresh token when the refresh response omits one', () => {
const prev = { access_token: 'old', refresh_token: 'keep-me', expires_in: 3600 };
const next = { access_token: 'new', expires_in: 3600 };
expect(carryRefreshToken(prev, next).refresh_token).toBe('keep-me');
expect(carryRefreshToken(prev, next).access_token).toBe('new');
});
it('keeps a rotated refresh token when the response does carry one', () => {
const prev = { access_token: 'old', refresh_token: 'old-r' };
const next = { access_token: 'new', refresh_token: 'new-r' };
expect(carryRefreshToken(prev, next).refresh_token).toBe('new-r');
});
});
describe('oauth: expiry math (mint-time based)', () => {
it('computes expiry from mintedAt + expires_in minus skew', () => {
const t = { access_token: 'a', expires_in: 3600 };
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
});
it('treats a missing expires_in as ttl 0', () => {
const t = { access_token: 'a' };
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
});
it('reports not-expired before, expired at/after the skewed boundary', () => {
const t = { access_token: 'a', expires_in: 3600 };
const minted = 1000;
const boundary = minted + 3600 - 60;
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
expect(isExpired(t, minted, boundary, 60)).toBe(true);
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest';
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
describe('panel: parseLaunchContext', () => {
it('reads snake_case worker/organization ids', () => {
expect(parseLaunchContext('?worker_id=w1&organization_id=o2')).toEqual({
workerId: 'w1',
organizationId: 'o2',
});
});
it('accepts camelCase fallbacks and defaults to empty', () => {
expect(parseLaunchContext('?workerId=w7')).toEqual({ workerId: 'w7', organizationId: '' });
expect(parseLaunchContext('')).toEqual({ workerId: '', organizationId: '' });
});
});
describe('panel: proxyUrl', () => {
it('prefixes /proxy and appends a query, dropping empties', () => {
expect(proxyUrl('', '/workers', { limit: '100', offset: '' })).toBe('/proxy/workers?limit=100');
});
it('strips a trailing slash from the base', () => {
expect(proxyUrl('https://workday.hanzo.ai/', '/workers')).toBe('https://workday.hanzo.ai/proxy/workers');
});
});
// jsonResponse builds a fetch Response-like from a body object.
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
// (url, init) arg tuple TypeScript expects.
function fetchMockOf(responder: () => Response) {
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
}
const workerEnvelope = {
total: 137,
data: [
{
id: 'w1',
descriptor: 'Logan McNeil',
businessTitle: 'VP, HR',
primarySupervisoryOrganization: { descriptor: 'Human Resources' },
},
],
};
describe('panel: createProxyClient — routes, cookie, parsing', () => {
it('lists workers with credentials and parses items + total', async () => {
const fetchMock = fetchMockOf(() => jsonResponse(workerEnvelope));
const client = createProxyClient({ fetch: fetchMock as any });
const { items, total } = await client.listWorkers({ limit: 100, offset: 200 });
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Logan McNeil');
expect(items[0].organization).toBe('Human Resources');
expect(total).toBe(137);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/workers?limit=100&offset=200');
expect((init as RequestInit).credentials).toBe('include');
});
it('gets one worker and parses it', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ id: 'w1', descriptor: 'Logan McNeil', primaryWorkEmail: 'l@x.co' }),
);
const client = createProxyClient({ fetch: fetchMock as any });
const worker = await client.getWorker('w1');
expect(worker.name).toBe('Logan McNeil');
expect(worker.email).toBe('l@x.co');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1');
});
it('lists direct reports on the worker route', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
const client = createProxyClient({ fetch: fetchMock as any });
await client.listDirectReports('w1', { limit: 50 });
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1/directReports?limit=50');
});
it('lists organizations and requisitions on their routes', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
const client = createProxyClient({ fetch: fetchMock as any });
await client.listOrganizations();
await client.listRequisitions({ offset: 10 });
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/organizations');
expect(String(fetchMock.mock.calls[1][0])).toBe('/proxy/requisitions?offset=10');
});
it('lists eligible absence types on the worker absenceTypes route', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
const client = createProxyClient({ fetch: fetchMock as any });
await client.listAbsenceTypes('w1');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1/absenceTypes');
});
it('runs a RaaS report and parses Report_Entry rows', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ Report_Entry: [{ Worker: { Descriptor: 'Amy' }, Balance: '12' }] }),
);
const client = createProxyClient({ fetch: fetchMock as any });
const rows = await client.report('lmcneil', 'Team_Time_Off', { asOf: '2026-07-01' });
expect(rows).toEqual([{ Worker: 'Amy', Balance: '12' }]);
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/report/lmcneil/Team_Time_Off?asOf=2026-07-01');
});
it('surfaces a proxy error with status + message', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ error: 'not authenticated' }, 401));
const client = createProxyClient({ fetch: fetchMock as any });
await expect(client.listWorkers()).rejects.toThrow(/401.*not authenticated/);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import {
parseTotal,
parseRestData,
parseWorker,
parseWorkers,
parseOrganization,
parseOrganizations,
parseJobRequisition,
parseJobRequisitions,
parseAbsenceTypes,
flattenEntry,
parseReportEntries,
} from '../src/workday-api.js';
// A realistic Workday Staffing v6 worker instance (trimmed to fields we surface).
const workerFixture = {
id: '0e44c92412d34b01ace61e80a47aaf6d',
descriptor: 'Logan McNeil',
primaryWorkEmail: 'lmcneil@workday.net',
businessTitle: 'Vice President, Human Resources',
isManager: true,
primarySupervisoryOrganization: { id: 'org-1', descriptor: 'Human Resources' },
primaryJob: {
id: 'job-1',
descriptor: 'P-00001 Vice President, Human Resources',
jobProfile: { id: 'jp-1', descriptor: 'Vice President, Human Resources' },
location: { id: 'loc-1', descriptor: 'San Francisco' },
manager: { id: 'mgr-1', descriptor: 'Joy Banks' },
},
};
// A `{ total, data }` list envelope, as Workday REST returns.
const workerList = { total: 137, data: [workerFixture, { descriptor: 'no id' }] };
describe('parse: envelope helpers', () => {
it('parseTotal reads the body total, 0 when missing/bad', () => {
expect(parseTotal({ total: 42 })).toBe(42);
expect(parseTotal({})).toBe(0);
expect(parseTotal({ total: 'many' })).toBe(0);
expect(parseTotal({ total: -3 })).toBe(0);
});
it('parseRestData reads the data array or a bare array', () => {
expect(parseRestData({ data: [1, 2] })).toEqual([1, 2]);
expect(parseRestData([3, 4])).toEqual([3, 4]);
expect(parseRestData({})).toEqual([]);
});
});
describe('parse: worker', () => {
it('reads name (descriptor), title, email, org, job, location, manager', () => {
const w = parseWorker(workerFixture);
expect(w.id).toBe('0e44c92412d34b01ace61e80a47aaf6d');
expect(w.name).toBe('Logan McNeil');
expect(w.businessTitle).toBe('Vice President, Human Resources');
expect(w.email).toBe('lmcneil@workday.net');
expect(w.isManager).toBe(true);
expect(w.jobTitle).toBe('Vice President, Human Resources'); // prefers jobProfile
expect(w.organization).toBe('Human Resources');
expect(w.location).toBe('San Francisco');
expect(w.managerName).toBe('Joy Banks');
});
it('falls back jobTitle to the primaryJob descriptor and tolerates missing fields', () => {
const w = parseWorker({ id: 'w', descriptor: 'Amy', primaryJob: { descriptor: 'Recruiter' } });
expect(w.jobTitle).toBe('Recruiter');
expect(w.email).toBe('');
expect(w.isManager).toBe(false);
expect(w.organization).toBe('');
});
it('parseWorkers reads the envelope and drops idless rows', () => {
const rows = parseWorkers(workerList);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Logan McNeil');
});
});
describe('parse: organization', () => {
it('reads a supervisory org reference shape', () => {
const o = parseOrganization({
id: 'org-1',
descriptor: 'Human Resources',
code: 'HR',
organizationType: { descriptor: 'Supervisory' },
manager: { descriptor: 'Logan McNeil' },
});
expect(o).toEqual({
id: 'org-1',
name: 'Human Resources',
code: 'HR',
type: 'Supervisory',
managerName: 'Logan McNeil',
});
});
it('parseOrganizations drops idless rows', () => {
expect(parseOrganizations({ data: [{ id: 'o', descriptor: 'X' }, { descriptor: 'no id' }] })).toHaveLength(1);
});
});
describe('parse: job requisition', () => {
it('reads title, status, openings, location, start date', () => {
const r = parseJobRequisition({
id: 'R-1',
descriptor: 'R-1 Senior Recruiter',
jobPostingTitle: 'Senior Recruiter',
jobRequisitionStatus: { descriptor: 'Open' },
numberOfOpenings: 3,
primaryLocation: { descriptor: 'Remote - USA' },
recruitingStartDate: '2026-06-01',
});
expect(r).toEqual({
id: 'R-1',
name: 'R-1 Senior Recruiter',
title: 'Senior Recruiter',
status: 'Open',
openings: 3,
location: 'Remote - USA',
startDate: '2026-06-01',
});
});
it('parseJobRequisitions defaults openings to 0 and drops idless rows', () => {
const rows = parseJobRequisitions({ data: [{ id: 'R', descriptor: 'x' }, { title: 'no id' }] });
expect(rows).toHaveLength(1);
expect(rows[0].openings).toBe(0);
});
});
describe('parse: absence types', () => {
it('reads id + descriptor and keeps rows with either', () => {
const types = parseAbsenceTypes({
total: 2,
data: [
{ id: 'a1', descriptor: 'Sick' },
{ id: 'a2', descriptor: 'Vacation' },
{ id: '', descriptor: '' },
],
});
expect(types).toEqual([
{ id: 'a1', name: 'Sick' },
{ id: 'a2', name: 'Vacation' },
]);
});
});
describe('parse: RaaS Report_Entry', () => {
it('flattenEntry stringifies scalars, references, and arrays', () => {
const row = flattenEntry({
Worker: { Descriptor: 'Logan McNeil', ID: 'wid' },
Days: 3,
Approved: true,
Types: [{ Descriptor: 'Sick' }, { Descriptor: 'Vacation' }],
Empty: null,
});
expect(row.Worker).toBe('Logan McNeil');
expect(row.Days).toBe('3');
expect(row.Approved).toBe('true');
expect(row.Types).toBe('Sick, Vacation');
expect(row.Empty).toBe('');
});
it('parseReportEntries reads the Report_Entry array (or a bare array)', () => {
const rows = parseReportEntries({
Report_Entry: [
{ Worker: { Descriptor: 'Amy' }, Time_Off_Balance: '12' },
{ Worker: { Descriptor: 'Bo' }, Time_Off_Balance: '4' },
],
});
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({ Worker: 'Amy', Time_Off_Balance: '12' });
expect(parseReportEntries([{ A: 1 }])).toEqual([{ A: '1' }]);
expect(parseReportEntries({})).toEqual([]);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import {
renderWorker,
renderOrganization,
renderJobRequisition,
renderAbsenceType,
renderReportRow,
buildPeopleContext,
contextNote,
workerSection,
directReportSection,
organizationSection,
requisitionSection,
absenceTypeSection,
timeOffSection,
} from '../src/people.js';
import type { Worker, Organization, JobRequisition, AbsenceType } from '../src/workday-api.js';
const worker: Worker = {
id: 'w1',
name: 'Logan McNeil',
businessTitle: 'Vice President, Human Resources',
email: 'lmcneil@workday.net',
isManager: true,
jobTitle: 'Vice President, Human Resources',
organization: 'Human Resources',
location: 'San Francisco',
managerName: 'Joy Banks',
};
describe('people: record rendering', () => {
it('renders a worker with title, org, and details, no invented fields', () => {
const text = renderWorker(worker);
expect(text).toContain('Logan McNeil');
expect(text).toContain('Vice President, Human Resources · Human Resources');
expect(text).toContain('Location: San Francisco');
expect(text).toContain('Manager: Joy Banks');
expect(text).toContain('Email: lmcneil@workday.net');
expect(text).toContain('People manager');
});
it('shows a distinct Job line only when jobTitle differs from businessTitle', () => {
const w: Worker = { ...worker, jobTitle: 'Recruiter' };
expect(renderWorker(w)).toContain('Job: Recruiter');
expect(renderWorker(worker)).not.toContain('Job: Vice President');
});
it('renders an org, requisition, absence type, and report row compactly', () => {
const org: Organization = { id: 'o1', name: 'Human Resources', code: 'HR', type: 'Supervisory', managerName: 'Logan McNeil' };
expect(renderOrganization(org)).toContain('Organization: Human Resources');
expect(renderOrganization(org)).toContain('Code: HR · Type: Supervisory · Manager: Logan McNeil');
const req: JobRequisition = { id: 'R-1', name: 'R-1', title: 'Senior Recruiter', status: 'Open', openings: 3, location: 'Remote', startDate: '2026-06-01' };
expect(renderJobRequisition(req)).toContain('Requisition R-1: Senior Recruiter');
expect(renderJobRequisition(req)).toContain('Status: Open · Openings: 3 · Location: Remote · Start: 2026-06-01');
const abs: AbsenceType = { id: 'a1', name: 'Sick' };
expect(renderAbsenceType(abs)).toBe('Absence type: Sick');
expect(renderReportRow({ Worker: 'Amy', Balance: '12', Empty: '' })).toBe('Worker: Amy · Balance: 12');
expect(renderReportRow({ Empty: '' })).toBe('');
});
});
describe('people: buildPeopleContext', () => {
it('returns empty for no blocks', () => {
expect(buildPeopleContext([{ title: 'Workers', blocks: [] }])).toEqual({
text: '',
totalBlocks: 0,
includedBlocks: 0,
truncated: false,
});
});
it('includes all blocks under section headers when within budget', () => {
const ctx = buildPeopleContext([
{ title: 'Workers', blocks: ['a', 'b'] },
{ title: 'Orgs', blocks: ['c'] },
]);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBe(3);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## Workers');
expect(ctx.text).toContain('## Orgs');
expect(ctx.text.indexOf('## Workers')).toBeLessThan(ctx.text.indexOf('## Orgs'));
});
it('windows in order and reports truncation when the budget is exceeded', () => {
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
const ctx = buildPeopleContext([{ title: 'Workers', blocks }], 60);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBeLessThan(3);
expect(ctx.truncated).toBe(true);
expect(ctx.text).toContain('xxx');
expect(ctx.text).not.toContain('zzz');
expect(ctx.text.length).toBeLessThanOrEqual(60);
});
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
const huge = 'q'.repeat(500);
const ctx = buildPeopleContext([{ title: 'Workers', blocks: [huge, 'next'] }], 50);
expect(ctx.includedBlocks).toBe(1);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBe(50);
expect(ctx.text.startsWith('## Workers')).toBe(true);
});
it('skips empty sections without charging a header', () => {
const ctx = buildPeopleContext([
{ title: 'Empty', blocks: [] },
{ title: 'Workers', blocks: ['a'] },
]);
expect(ctx.text).not.toContain('## Empty');
expect(ctx.text).toContain('## Workers');
expect(ctx.includedBlocks).toBe(1);
});
});
describe('people: contextNote', () => {
it('is honest about no records', () => {
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No Workday records/);
});
it('reports full coverage', () => {
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 items/);
});
it('reports truncation with the counts', () => {
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 items/);
});
});
describe('people: section builders assemble a real context', () => {
it('turns typed records into a windowed context end to end', () => {
const ctx = buildPeopleContext([
workerSection([worker]),
directReportSection([{ ...worker, id: 'w2', name: 'Amy Wong', businessTitle: 'Recruiter', isManager: false }]),
organizationSection([{ id: 'o1', name: 'Human Resources', code: 'HR', type: 'Supervisory', managerName: 'Logan McNeil' }]),
requisitionSection([{ id: 'R-1', name: 'R-1', title: 'Senior Recruiter', status: 'Open', openings: 2, location: '', startDate: '' }]),
absenceTypeSection([{ id: 'a1', name: 'Sick' }]),
timeOffSection([{ Worker: 'Amy Wong', Balance: '12' }, { Empty: '' }]),
]);
expect(ctx.totalBlocks).toBe(6); // the all-empty time-off row is dropped by timeOffSection
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## Workers');
expect(ctx.text).toContain('Logan McNeil');
expect(ctx.text).toContain('## Direct reports');
expect(ctx.text).toContain('Amy Wong');
expect(ctx.text).toContain('## Organization');
expect(ctx.text).toContain('## Job requisitions');
expect(ctx.text).toContain('## Absence types');
expect(ctx.text).toContain('## Time off');
expect(ctx.text).toContain('Worker: Amy Wong · Balance: 12');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import {
MAX_LIMIT,
pageParams,
restGet,
raasGet,
listWorkers,
getWorker,
listDirectReports,
listSupervisoryOrganizations,
getSupervisoryOrganization,
listJobRequisitions,
getJobRequisition,
listEligibleAbsenceTypes,
timeOffReport,
type Scope,
} from '../src/workday-api.js';
import { STAFFING } from '../src/config.js';
const scope: Scope = {
host: 'https://wd2-impl-services1.workday.com',
tenant: 'acme',
accessToken: 'at-123',
};
function q(url: string): URLSearchParams {
return new URL(url).searchParams;
}
function path(url: string): string {
return new URL(url).pathname;
}
describe('api: pageParams', () => {
it('emits nothing for no page', () => {
expect(pageParams(undefined)).toEqual({});
});
it('emits limit + offset when set', () => {
expect(pageParams({ limit: 50, offset: 100 })).toEqual({ limit: '50', offset: '100' });
});
it('clamps limit to the documented max', () => {
expect(pageParams({ limit: 500 }).limit).toBe(String(MAX_LIMIT));
});
it('drops a zero/absent offset (0 is the default) and non-positive limit', () => {
expect(pageParams({ offset: 0 })).toEqual({});
expect(pageParams({ limit: 0, offset: -5 })).toEqual({});
});
});
describe('api: auth headers', () => {
it('every read carries the Bearer token and a JSON Accept', () => {
for (const req of [
listWorkers(scope),
getWorker(scope, 'w1'),
listDirectReports(scope, 'w1'),
listSupervisoryOrganizations(scope),
getSupervisoryOrganization(scope, 'o1'),
listJobRequisitions(scope),
getJobRequisition(scope, 'r1'),
listEligibleAbsenceTypes(scope, 'w1'),
timeOffReport(scope, 'lmcneil', 'Team_Time_Off'),
]) {
expect(req.headers.Authorization).toBe('Bearer at-123');
expect(req.headers.Accept).toBe('application/json');
}
});
});
describe('api: restGet service routing', () => {
it('routes staffing v6 with tenant in the path', () => {
const req = restGet(scope, STAFFING, '/workers', { limit: '10' });
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
expect(q(req.url).get('limit')).toBe('10');
});
});
describe('api: workers (staffing)', () => {
it('lists workers with pagination', () => {
const req = listWorkers(scope, { limit: 100, offset: 200 });
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
expect(q(req.url).get('limit')).toBe('100');
expect(q(req.url).get('offset')).toBe('200');
});
it('gets one worker by id', () => {
expect(path(getWorker(scope, 'abc123').url)).toBe('/ccx/api/staffing/v6/acme/workers/abc123');
});
it('lists direct reports under the worker path', () => {
expect(path(listDirectReports(scope, 'abc123').url)).toBe(
'/ccx/api/staffing/v6/acme/workers/abc123/directReports',
);
});
});
describe('api: organizations (staffing)', () => {
it('lists supervisory organizations', () => {
expect(path(listSupervisoryOrganizations(scope).url)).toBe(
'/ccx/api/staffing/v6/acme/supervisoryOrganizations',
);
});
it('gets one organization by id', () => {
expect(path(getSupervisoryOrganization(scope, 'o9').url)).toBe(
'/ccx/api/staffing/v6/acme/supervisoryOrganizations/o9',
);
});
});
describe('api: job requisitions (recruiting)', () => {
it('lists requisitions on the recruiting v4 service', () => {
expect(path(listJobRequisitions(scope).url)).toBe('/ccx/api/recruiting/v4/acme/jobRequisitions');
});
it('gets one requisition by id', () => {
expect(path(getJobRequisition(scope, 'R-42').url)).toBe(
'/ccx/api/recruiting/v4/acme/jobRequisitions/R-42',
);
});
});
describe('api: absence (time off)', () => {
it('lists eligible absence types on the absenceManagement v1 service', () => {
expect(path(listEligibleAbsenceTypes(scope, 'w1').url)).toBe(
'/ccx/api/absenceManagement/v1/acme/workers/w1/eligibleAbsenceTypes',
);
});
});
describe('api: RaaS report', () => {
it('targets the customreport2 path and forces format=json', () => {
const req = timeOffReport(scope, 'lmcneil', 'Team_Time_Off', { asOf: '2026-07-01' });
expect(path(req.url)).toBe('/ccx/service/customreport2/acme/lmcneil/Team_Time_Off');
expect(q(req.url).get('format')).toBe('json');
expect(q(req.url).get('asOf')).toBe('2026-07-01');
});
it('defaults format=json even without extra params', () => {
expect(q(raasGet(scope, 'owner', 'Rep').url).get('format')).toBe('json');
});
});
describe('api: path segment encoding', () => {
it('encodes ids defensively so a stray value cannot traverse the path', () => {
expect(getWorker(scope, 'a/b c').url).toContain('/workers/a%2Fb%20c');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -31,6 +31,7 @@ packages:
- 'packages/teams'
- 'packages/tools'
- 'packages/vscode'
- 'packages/workday'
- 'packages/zendesk'
- 'apps/*'