Compare commits

...
Author SHA1 Message Date
Codeman maintainer 59b92f2991 fix(spreadsheet-preview): open openpyxl workbooks that have a comment, table or chart
The XLSX preview hands an admitted copy of the workbook to ExcelJS 4.4.0,
which links each worksheet to its comments, comment notes (VML), tables and
drawing by looking the relationship Target up verbatim in maps keyed by its
own spelling, then dereferences the result unchecked. openpyxl, the library
most agents write .xlsx files with, uses absolute Targets
(/xl/tables/table1.xml), its own comment part name (xl/comments/comment1.xml,
which ExcelJS never loads) and a drawing XML ExcelJS parses to nothing. One
comment, table or chart was enough for "Spreadsheet preview failed".

repairExcelJsModel() (spreadsheet-xlsx-core.js) runs on ExcelJS's parsed
model right before its reconcile: it re-points a link to the key ExcelJS
stored for the same part (so an openpyxl table is kept) and drops a link,
tablePart, drawing or hyperlink reference that would dereference nothing.
It adds nothing and resolves keys as own properties only. The worker wraps
workbook.xlsx.reconcile per instance to call it, and throws if a future
ExcelJS renames that method. SPREADSHEET_ASSET_VERSION is refreshed.

Tests: unit tests on model objects, and three real openpyxl 3.1.5
workbooks in test/fixtures/xlsx that fail the worker tests without the hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:37:36 +02:00
Ark0NandClaude Opus 5.5 aa3730aace Merge pull request #589: docs(cron): document job API and launch statuses
Fixes #587. Supersedes #597, closed as a duplicate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:41:08 +02:00
Codeman maintainer 6234aae919 docs(readme): codeman skill GIF with the CRT tile grid and a mixed fleet
The agent skill section now shows a real run end to end: one short
prompt typed into Claude Code, the tile grid powering on with the CRT
entrance, then a DeepSeek Harness worker on a local qwen model and a
Claude Code worker powering on as new tiles, with lineage lines back to
the lead. 1280 wide, linking a 3600x2025 still.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:29:01 +02:00
w3lld1 34439d18e5 docs(cron): address linked guide and run history review
Signed-off-by: w3lld1 <42353747+w3lld1@users.noreply.github.com>
2026-10-10 09:27:54 +02:00
w3lld1 45a205b4e5 docs(cron): document job API and launch statuses
I corrected the toolbar location and documented the existing cron request and response fields, with checks for route, schema, and status coverage.
2026-10-10 06:49:58 +02:00
19 changed files with 467 additions and 12 deletions
+5
View File
@@ -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".
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+26
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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;
+97
View File
@@ -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,
+56
View File
@@ -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");
});
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
View File
@@ -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']));
});
});
+125
View File
@@ -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();
});
});