mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 18:59:42 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b92f2991 | ||
|
|
aa3730aace | ||
|
|
6234aae919 | ||
|
|
34439d18e5 | ||
|
|
45a205b4e5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Spreadsheet preview: workbooks written by openpyxl (the Python library most agents use to make .xlsx files) now open when they contain a cell comment, a table or a chart. Before, any one of those made the whole preview fail with "Spreadsheet preview failed".
|
||||
@@ -736,7 +736,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
@@ -87,6 +87,93 @@ the HTTP status.
|
||||
|
||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
|
||||
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
|
||||
is the unversioned alias. These routes use the response envelope above; the table
|
||||
lists the value inside `data` on success.
|
||||
|
||||
| Method | Path | Request body | Response `data` |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
|
||||
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
|
||||
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
|
||||
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
|
||||
|
||||
### Job request fields
|
||||
|
||||
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
|
||||
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
|
||||
are required according to the selected prompt and schedule:
|
||||
|
||||
| Field | Type / validation |
|
||||
| --- | --- |
|
||||
| `name` | String, 1–200 characters |
|
||||
| `agentType` | A supported session mode (including `shell`) |
|
||||
| `workingDir` | Existing, allowed working-directory path |
|
||||
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
|
||||
| `promptMode` | `inline_text` or `prompt_file_path` |
|
||||
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
|
||||
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
|
||||
| `inputMode` | `paste` or `typed` |
|
||||
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
|
||||
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
|
||||
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
|
||||
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
|
||||
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
|
||||
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
|
||||
| `enabled` | Boolean |
|
||||
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
|
||||
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
|
||||
| `notes` | Optional string, at most 2000 characters |
|
||||
|
||||
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
|
||||
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
|
||||
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
|
||||
policy, and does not change the schedule. `activeAgents` counts live sessions of
|
||||
the same agent type, excluding sessions created by this job.
|
||||
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
|
||||
also closes the previous run's session before launching, even if it is still working.
|
||||
|
||||
### Job and run response fields
|
||||
|
||||
`CronJob` contains the request fields plus server-maintained `id`, optional
|
||||
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
|
||||
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
|
||||
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
|
||||
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
|
||||
|
||||
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
|
||||
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
|
||||
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
|
||||
Run times are also Unix milliseconds. Status is one of `created`,
|
||||
`session_started`, `prompt_sent`, `failed`, or `skipped`.
|
||||
|
||||
Prompt delivery continues asynchronously after session launch, so `Run Now` can
|
||||
return `session_started` before the prompt is sent. Read run history for subsequent
|
||||
updates, but do not assume a terminal status will follow: if the session is closed
|
||||
during the readiness wait or the server restarts before delivery, the run can remain
|
||||
`session_started` indefinitely with `finishedAt: null`.
|
||||
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
|
||||
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
|
||||
|
||||
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
|
||||
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
|
||||
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
|
||||
launch-command job without the required privilege grant. Invalid definitions or
|
||||
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
|
||||
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
|
||||
|
||||
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
|
||||
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
|
||||
`cron:runUpdated` SSE events.
|
||||
|
||||
## Long-polling (agent wait)
|
||||
|
||||
Three calls block until something happens instead of answering immediately. They
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the header.
|
||||
1. Click **⏰ Cron** in the bottom toolbar.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
+15
-7
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
|
||||
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
||||
dependencies and open a PR.*
|
||||
|
||||
The ⏰ **Cron** header button is opt-in. Turn it on in
|
||||
**App Settings → Header & Panels**.
|
||||
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
|
||||
**App Settings → Header & Panels → Scheduling**.
|
||||
|
||||
## Creating a job
|
||||
|
||||
@@ -96,11 +96,19 @@ The skip policy has the details you would want it to have:
|
||||
|
||||
Every fire is recorded per job, with a status:
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `created` | The run started and a session was created. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | The prompt could not be resolved, or the working directory was gone. |
|
||||
| Status | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `created` | The run record was created; the session has not started yet. |
|
||||
| `session_started` | The session started; prompt delivery is still pending. |
|
||||
| `prompt_sent` | The prompt was sent to the session. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
|
||||
|
||||
History records whether the session started and the prompt was delivered, **not whether
|
||||
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
|
||||
If the session is closed during the readiness wait or the server restarts before
|
||||
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
|
||||
clients must not poll forever waiting for a terminal status.
|
||||
|
||||
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
||||
same job to re-trigger.
|
||||
|
||||
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
|
||||
Each route module documents its own endpoints in its file header.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
|
||||
versioned endpoints are:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | List jobs |
|
||||
| POST | `/api/v1/cron/jobs` | Create a job |
|
||||
| GET | `/api/v1/cron/jobs/:id` | Read a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
|
||||
| GET | `/api/v1/cron/runs` | Read all accessible run history |
|
||||
|
||||
Responses use the envelope above: job lists/history have arrays in `data`, a
|
||||
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
|
||||
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
|
||||
`session_started` and `prompt_sent` describe launch/delivery, not task success.
|
||||
|
||||
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
|
||||
lists all request and response fields, validation, and ownership restrictions.
|
||||
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
|
||||
|
||||
## Long-polling instead of polling
|
||||
|
||||
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
||||
|
||||
@@ -258,7 +258,7 @@ Two extras depending on the device:
|
||||
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
|
||||
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| File Viewer | Header | [Working With Files](Working-With-Files) |
|
||||
|
||||
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||
// ExcelJS follows a worksheet's comment, VML, table and drawing links
|
||||
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
|
||||
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
|
||||
// once on the parsed model; repair the model first (core.repairExcelJsModel).
|
||||
// Throws if a future ExcelJS renames reconcile, rather than failing again.
|
||||
const reconcile = nextWorkbook.xlsx.reconcile;
|
||||
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
|
||||
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
|
||||
core.repairExcelJsModel(model);
|
||||
return reconcile.call(this, model, options);
|
||||
};
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const SPREADSHEET_ASSET_VERSION = '0833a414e785';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
|
||||
@@ -472,6 +472,102 @@
|
||||
return zipApi.zipSync(admission.entries, { level: 0 });
|
||||
}
|
||||
|
||||
// ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`)
|
||||
// links a worksheet to its comments, comment notes (VML), tables and drawing by
|
||||
// looking the relationship Target up VERBATIM in maps keyed by its OWN spelling
|
||||
// (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`,
|
||||
// `drawing1`), then dereferences the result unchecked. openpyxl, which most
|
||||
// agents write spreadsheets with, uses absolute Targets, its own part names
|
||||
// (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so
|
||||
// a workbook with ONE comment, table or chart threw ("reading 'comments'",
|
||||
// "'name'", "'anchors'") before a single cell was shown.
|
||||
//
|
||||
// This runs on ExcelJS's parsed model right before that reconcile. It only
|
||||
// re-points a link to the key ExcelJS itself stored for the same part, or drops
|
||||
// a link (or a drawing) that would dereference nothing; nothing is added. The
|
||||
// preview shows none of these parts. Lookups are own-property only, so a Target
|
||||
// or rId such as `__proto__` cannot resolve through Object.prototype.
|
||||
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/;
|
||||
|
||||
function ownValue(map, key) {
|
||||
return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
|
||||
}
|
||||
|
||||
// The key ExcelJS gives a part it loaded from `xl/<path>` is `../<path>`
|
||||
// (relative to xl/worksheets/). A Target that names the same part another way
|
||||
// (`/xl/tables/table1.xml`) is rewritten to that key.
|
||||
function worksheetRelativeTarget(target) {
|
||||
const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`);
|
||||
return path.startsWith('xl/') ? `../${path.slice(3)}` : null;
|
||||
}
|
||||
|
||||
function resolveTarget(rel, map, usable) {
|
||||
if (typeof rel.Target !== 'string') return false;
|
||||
if (usable(ownValue(map, rel.Target))) return true;
|
||||
const key = worksheetRelativeTarget(rel.Target);
|
||||
if (key && usable(ownValue(map, key))) {
|
||||
rel.Target = key;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function repairExcelJsModel(model) {
|
||||
if (!model || typeof model !== 'object') return model;
|
||||
const isObject = (value) => Boolean(value) && typeof value === 'object';
|
||||
const drawings = isObject(model.drawings) ? model.drawings : {};
|
||||
// ExcelJS reconciles every drawing that has rels and reads `.anchors` off it.
|
||||
for (const name of Object.keys(drawings)) {
|
||||
if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) {
|
||||
delete drawings[name];
|
||||
if (isObject(model.drawingRels)) delete model.drawingRels[name];
|
||||
}
|
||||
}
|
||||
// ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes.
|
||||
if (isObject(model.tables)) {
|
||||
for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key];
|
||||
}
|
||||
const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {};
|
||||
for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) {
|
||||
if (!isObject(worksheet)) continue;
|
||||
const list = ownValue(worksheetRels, String(worksheet.sheetNo));
|
||||
const rels = Array.isArray(list) ? list : [];
|
||||
const kept = rels.filter((rel) => {
|
||||
if (!isObject(rel)) return false;
|
||||
if (rel.Type === `${REL_TYPE}comments`) return resolveTarget(rel, model.comments, isObject);
|
||||
if (rel.Type === `${REL_TYPE}vmlDrawing`) {
|
||||
return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments));
|
||||
}
|
||||
if (rel.Type === `${REL_TYPE}table`) return resolveTarget(rel, model.tables, isObject);
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(list)) worksheetRels[String(worksheet.sheetNo)] = kept;
|
||||
const byId = new Map(kept.map((rel) => [rel.Id, rel]));
|
||||
// `<tablePart r:id>`, `<drawing r:id>` and `<hyperlink r:id>` are followed
|
||||
// through the rels unchecked, so each must name a rel that is still there.
|
||||
if (Array.isArray(worksheet.tables)) {
|
||||
worksheet.tables = worksheet.tables.filter((part) => {
|
||||
const rel = isObject(part) ? byId.get(part.rId) : undefined;
|
||||
return Boolean(rel) && rel.Type === `${REL_TYPE}table`;
|
||||
});
|
||||
}
|
||||
if (isObject(worksheet.drawing)) {
|
||||
const rel = byId.get(worksheet.drawing.rId);
|
||||
const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null;
|
||||
if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) {
|
||||
worksheet.drawing = undefined;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(worksheet.hyperlinks)) {
|
||||
worksheet.hyperlinks = worksheet.hyperlinks.filter(
|
||||
(link) => isObject(link) && (!link.rId || byId.has(link.rId))
|
||||
);
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
|
||||
if (!match) return null;
|
||||
@@ -983,6 +1079,7 @@
|
||||
createXmlCounter,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
repairExcelJsModel,
|
||||
excelJsEntryName,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const reference = read('docs/api-reference.md');
|
||||
const wiki = read('docs/wiki/HTTP-API.md');
|
||||
const guide = read('docs/wiki/Cron-Jobs.md');
|
||||
|
||||
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
|
||||
|
||||
describe('cron documentation', () => {
|
||||
it('documents every cron route in both API references', () => {
|
||||
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
|
||||
expect(routes).toHaveLength(9);
|
||||
for (const [, method, path] of routes) {
|
||||
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
|
||||
expect(cronSection(reference)).toContain(row);
|
||||
expect(cronSection(wiki)).toContain(row);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every accepted job field and validates the guide example', () => {
|
||||
for (const field of Object.keys(CronJobSchema.shape)) {
|
||||
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
|
||||
}
|
||||
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
|
||||
});
|
||||
|
||||
it('documents all run statuses without treating prompt delivery as task success', () => {
|
||||
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
|
||||
expect(statusType).toBeDefined();
|
||||
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
|
||||
expect(guide).toContain(`| \`${status}\``);
|
||||
expect(cronSection(reference)).toContain(`\`${status}\``);
|
||||
}
|
||||
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
|
||||
expect(guide).toContain('bottom toolbar');
|
||||
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
|
||||
});
|
||||
|
||||
it('keeps linked pages consistent and explains non-terminal launch history', () => {
|
||||
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
|
||||
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
|
||||
for (const text of [reference, guide]) {
|
||||
const normalized = text.replace(/\s+/g, ' ');
|
||||
expect(normalized).toContain('session is closed during the readiness wait');
|
||||
expect(normalized).toContain('server restarts before delivery');
|
||||
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
|
||||
}
|
||||
expect(reference).toContain("also closes the previous run's session before launching");
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1070,3 +1070,41 @@ describe('spreadsheet preview worker: element budget', () => {
|
||||
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// Real files written by openpyxl 3.1.5, the library most agents write
|
||||
// spreadsheets with: a "Data" sheet (month, revenue x6) plus ONE feature each.
|
||||
// ExcelJS threw on all three before repairExcelJsModel, so the preview failed
|
||||
// on the whole workbook. Regenerate with openpyxl's Comment, Table and BarChart.
|
||||
function openpyxlFixture(name: string): ArrayBuffer {
|
||||
return toArrayBuffer(new Uint8Array(readFileSync(resolve(root, 'test/fixtures/xlsx', name))));
|
||||
}
|
||||
|
||||
describe('spreadsheet preview worker: workbooks written by openpyxl', () => {
|
||||
for (const [file, feature] of [
|
||||
['openpyxl-comment.xlsx', 'a cell comment'],
|
||||
['openpyxl-table.xlsx', 'a table'],
|
||||
['openpyxl-chart.xlsx', 'a bar chart'],
|
||||
] as const) {
|
||||
it(`previews a sheet that has ${feature}`, async () => {
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, openpyxlFixture(file));
|
||||
expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Data']);
|
||||
expect(metadata.sheets[0]).toMatchObject({ rows: 7, cols: 2 });
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 7, c2: 2 });
|
||||
expect(tile.type).toBe('tile');
|
||||
expect(tile.cells).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ row: 1, col: 1, text: 'month' }),
|
||||
expect.objectContaining({ row: 7, col: 1, text: '2026-06' }),
|
||||
expect.objectContaining({ row: 7, col: 2, text: '600' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('still flags the chart it cannot show', async () => {
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, openpyxlFixture('openpyxl-chart.xlsx'));
|
||||
expect(metadata.warnings).toEqual(expect.arrayContaining(['charts', 'drawings']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ type Core = {
|
||||
limits: Record<string, number>
|
||||
): { push(chunk: Uint8Array, final: boolean): void };
|
||||
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
|
||||
repairExcelJsModel(model: unknown): unknown;
|
||||
excelJsEntryName(name: string): string;
|
||||
parseCellRef(ref: string): { row: number; col: number } | null;
|
||||
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
|
||||
@@ -803,3 +804,127 @@ describe('spreadsheet XLSX colour resolution', () => {
|
||||
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ExcelJS 4.4.0's parsed model at the moment it reconciles, reduced to the parts
|
||||
// repairExcelJsModel reads. `excelShaped()` is what Excel itself writes (and
|
||||
// ExcelJS links cleanly); the openpyxl cases below are what made it throw.
|
||||
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
const rel = (Id: string, type: string, Target: string) => ({ Id, Type: REL + type, Target });
|
||||
type Model = Record<string, any>;
|
||||
|
||||
function excelShaped(): Model {
|
||||
return {
|
||||
worksheets: [
|
||||
{
|
||||
sheetNo: '1',
|
||||
tables: [{ rId: 'rId3' }],
|
||||
drawing: { rId: 'rId4' },
|
||||
hyperlinks: [{ rId: 'rId5', address: 'A1' }, { address: 'B2' }],
|
||||
},
|
||||
],
|
||||
worksheetRels: {
|
||||
'1': [
|
||||
rel('rId1', 'comments', '../comments1.xml'),
|
||||
rel('rId2', 'vmlDrawing', '../drawings/vmlDrawing1.vml'),
|
||||
rel('rId3', 'table', '../tables/table1.xml'),
|
||||
rel('rId4', 'drawing', '../drawings/drawing1.xml'),
|
||||
rel('rId5', 'hyperlink', 'https://example.com'),
|
||||
],
|
||||
},
|
||||
comments: { '../comments1.xml': { comments: [{ ref: 'B2' }] } },
|
||||
vmlDrawings: { '../drawings/vmlDrawing1.vml': { comments: [{}] } },
|
||||
tables: { '../tables/table1.xml': { name: 'Sales' } },
|
||||
drawings: { drawing1: { anchors: [] } },
|
||||
drawingRels: { drawing1: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('repairExcelJsModel: links ExcelJS would dereference unchecked', () => {
|
||||
it('leaves a model Excel itself wrote untouched', () => {
|
||||
const model = excelShaped();
|
||||
const before = structuredClone(model);
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model).toEqual(before);
|
||||
});
|
||||
|
||||
it("re-points openpyxl's absolute table Target to the key ExcelJS stored that table under", () => {
|
||||
const model = excelShaped();
|
||||
model.worksheetRels['1'][2].Target = '/xl/tables/table1.xml';
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheetRels['1'][2].Target).toBe('../tables/table1.xml');
|
||||
expect(model.worksheets[0].tables).toEqual([{ rId: 'rId3' }]);
|
||||
});
|
||||
|
||||
it('drops comment and note links that resolve to nothing (openpyxl names the parts its own way)', () => {
|
||||
const model = excelShaped();
|
||||
model.worksheetRels['1'][0].Target = '/xl/comments/comment1.xml';
|
||||
model.worksheetRels['1'][1].Target = '/xl/drawings/commentsDrawing1.vml';
|
||||
model.comments = {};
|
||||
model.vmlDrawings = {};
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId3', 'rId4', 'rId5']);
|
||||
});
|
||||
|
||||
it('drops a note link whose VML has no comments list, as ExcelJS would index into it', () => {
|
||||
const model = excelShaped();
|
||||
model.vmlDrawings['../drawings/vmlDrawing1.vml'] = {};
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId2')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a drawing ExcelJS parsed to nothing and unhooks the sheet from it (openpyxl charts)', () => {
|
||||
const model = excelShaped();
|
||||
model.drawings.drawing1 = undefined;
|
||||
model.worksheetRels['1'][3].Target = '/xl/drawings/drawing1.xml';
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.drawings).toEqual({});
|
||||
expect(model.drawingRels).toEqual({});
|
||||
expect(model.worksheets[0].drawing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a drawing link whose Target ExcelJS does not try to resolve', () => {
|
||||
const model = excelShaped();
|
||||
model.worksheetRels['1'][3].Target = 'drawing-without-extension';
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheets[0].drawing).toEqual({ rId: 'rId4' });
|
||||
});
|
||||
|
||||
it('unhooks a tablePart, drawing or hyperlink whose rId names no relationship', () => {
|
||||
const model = excelShaped();
|
||||
model.worksheets[0].tables = [{ rId: 'missing' }, { rId: 'rId5' }];
|
||||
model.worksheets[0].drawing = { rId: 'missing' };
|
||||
model.worksheets[0].hyperlinks = [{ rId: 'missing', address: 'A1' }, { address: 'B2' }];
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheets[0].tables).toEqual([]);
|
||||
expect(model.worksheets[0].drawing).toBeUndefined();
|
||||
expect(model.worksheets[0].hyperlinks).toEqual([{ address: 'B2' }]);
|
||||
});
|
||||
|
||||
it('drops a table rel whose part ExcelJS never stored, and its tablePart with it', () => {
|
||||
const model = excelShaped();
|
||||
model.worksheetRels['1'][2].Target = '/xl/somewhere/else.xml';
|
||||
model.tables = { '../tables/table1.xml': undefined };
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.tables).toEqual({});
|
||||
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId3')).toBe(false);
|
||||
expect(model.worksheets[0].tables).toEqual([]);
|
||||
});
|
||||
|
||||
it('never resolves a Target or rId through Object.prototype', () => {
|
||||
const model = excelShaped();
|
||||
model.worksheetRels['1'][0].Target = '__proto__';
|
||||
model.worksheetRels['1'][2].Target = 'constructor';
|
||||
model.worksheets[0].tables = [{ rId: 'toString' }];
|
||||
model.worksheets[0].hyperlinks = [{ rId: 'hasOwnProperty', address: 'A1' }];
|
||||
core.repairExcelJsModel(model);
|
||||
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId2', 'rId4', 'rId5']);
|
||||
expect(model.worksheets[0].tables).toEqual([]);
|
||||
expect(model.worksheets[0].hyperlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates a model with no worksheets, rels or part maps', () => {
|
||||
expect(() => core.repairExcelJsModel({})).not.toThrow();
|
||||
expect(() => core.repairExcelJsModel({ worksheets: [null, { sheetNo: '9' }] })).not.toThrow();
|
||||
expect(core.repairExcelJsModel(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user