Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/concepts/data-contracts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@ checkpointed pipeline, so writes are reversible:
datasets on a schedule.

Both run through the same snapshot-before-write machinery the agent edit loop uses. See
[MCP Server](/mcp) for how an agent drives actions (`oi.app().runAction`) and connectors
[MCP Server](/mcp) for how an agent drives actions (`oi.app().runActions`) and connectors
(`oi.app().runSync`) safely, and the [Manifest Reference](/reference/manifest) for their full
declaration shape.
8 changes: 4 additions & 4 deletions apps/docs/content/docs/data/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ runs it over [MCP](/mcp).

```mermaid
flowchart LR
A["Agent: oi.app().runAction"] --> V{Validate rows}
A["Agent: oi.app().runActions"] --> V{Validate rows}
V -- bad row --> X[Reject, nothing written]
V -- ok --> S[Snapshot + write file]
S --> L[Live refresh]
Expand Down Expand Up @@ -77,7 +77,7 @@ that exist.

## What happens on a write

An agent calls `oi.app().runAction(name, rows)`. Before a single byte lands:
An agent calls `oi.app().runActions([{ action, rows }])`. Before a single byte lands:

- **Every row is validated** against the resolved schema. One bad row (wrong type, a value
outside `min`/`max`, an unknown column) rejects the whole call with an error naming the row
Expand All @@ -98,7 +98,7 @@ Actions belong to the agent edit loop, not to a CLI command. An agent:

1. Calls **`oi.app().listActions()`** to get each declared action and its resolved row JSON Schema
(the live schema merged with `fields`). That schema is its grounding for a valid row.
2. Calls **`oi.app().runAction(name, rows)`**, which validates and inserts as above, returning the
2. Calls **`oi.app().runActions([{ action, rows }])`**, which validates and inserts as above, returning the
count `inserted` and a `checkpoint_id`.

<Callout type="info" title="Note">
Expand All @@ -114,7 +114,7 @@ past validation or escape `rollback`.
An action isn't agent-only. Drop a [`form.entry`](/islands/content-and-layout#formentry) island on a
page and point it at an action, and the runtime renders a form — one typed input per field, the
action's types, enums, ranges, and defaults carried straight through — with a submit button that
inserts a row. It runs the very same write path `runAction` does: validate, snapshot, insert, then
inserts a row. It runs the very same write path `runActions` does: validate, snapshot, insert, then
the bound dataset's islands refresh live. The form is the human-facing mirror of the agent call,
authored by reusing the action rather than re-declaring its fields.

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/islands/content-and-layout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,15 @@ is a full workspace bound to a `data/` tree with an editable CSV and the three v

**What it is.** A data-entry form card bound to a manifest [action](/data/actions). **When to use
it:** when a human, not just an agent, should add rows — log an incident, record a meal, file an
expense — right next to the data it feeds. It's the human-facing mirror of the agent's `runAction`:
expense — right next to the data it feeds. It's the human-facing mirror of the agent's `runActions`:
you don't re-declare fields, you point it at an action and it derives one typed input per field from
that action's resolved row schema. So it binds **no dataset** of its own — the action already names
the target.

The card renders a text, number, or date input per field, a dropdown for an `enum` field, and a
checkbox for a boolean, with a submit button in the bottom-right. A field is required unless the
action gives it a `default`. On submit it inserts a row through the **same path** as the MCP
`oi.app().runAction` — the row is validated, snapshotted to history for `rollback`, then inserted —
`oi.app().runActions` — the row is validated, snapshotted to history for `rollback`, then inserted —
and the bound dataset's islands refresh live.

```jsonc title="manifest.json"
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/content/docs/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ discovers and runs them:
- **`app.listActions()`** returns each declared action with its **resolved row JSON Schema**
(derived from the live data, merged with the action's `fields` overrides). This is the
agent's grounding for what a valid row looks like.
- **`app.runAction(name, rows)`** validates **every** row against that schema first. A single bad
- **`app.runActions([{ action, rows }])`** validates **every** row against that schema first. A single bad
row rejects the whole call with an error naming the row index and field, and **nothing is
written.** On success the target file is snapshotted (so `rollback` covers it) and the
result reports the rows `inserted` plus a `checkpoint_id`.
Expand Down Expand Up @@ -346,9 +346,9 @@ Every guarantee is structural, not advisory:
- **Sandboxed code.** `execute` runs your program in a `node:vm` with only `oi` in scope — no
`require`, no `process`, no network, no filesystem. The only way to act is through an `oi`
method, and every `oi` method is the same validated surface above.
- **Validate before write.** `patchManifest`, `replaceManifest`, and `runAction` all fail closed:
- **Validate before write.** `patchManifest`, `replaceManifest`, and `runActions` all fail closed:
an invalid manifest or a bad row never reaches disk.
- **Snapshot before change.** `applyEdit`, `runAction`, and `runSync` each snapshot to
- **Snapshot before change.** `applyEdit`, `runActions`, and `runSync` each snapshot to
`.openislands/history/` first, and `rollback` restores any of them byte-for-byte. History is
count- and byte-capped, oldest pruned first.
- **Path confinement.** Writes are scoped to the project's declared `source` files; there is
Expand Down
38 changes: 35 additions & 3 deletions apps/docs/public/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Prefer `patchManifest` — you never re-send (or re-typo) the whole document.
`label`, …). A page uses either a flat `islands` array or tabbed `groups`, not both — a group is
`{ id, title?, islands }` and renders as a tab. Groups are page structure, not an island, so they
won't show up in `listIslands()`.
- **actions** — a typed `insert` into a writable file dataset; append rows with `app.runAction`.
- **actions** — a typed `insert` into a writable file dataset; append rows with `app.runActions`.
- **queries** — a typed, parameterized read; run it with `app.runQuery`. No raw SQL — heavy shaping
lives in a `sql` transform the query reads from.

Expand Down Expand Up @@ -179,11 +179,43 @@ return s.ok ? await app.applyEdit(s.proposal_id) : s.errors;
await app.patchManifest({ actions: { log_txn: { dataset: "transactions", mode: "insert",
fields: { amount: { type: "number", min: 0 }, kind: { type: "string", enum: ["in", "out"] } } } } });
// ...applyEdit, then append rows:
return await app.runAction("log_txn", [{ amount: 50, kind: "in" }]);
return await app.runActions([{ action: "log_txn", rows: [{ amount: 50, kind: "in" }] }]);
```

Every row is validated all-or-nothing and the file is snapshotted for rollback.

**Atomic multi-write (parent + children).** `app.runActions(calls, { atomic })` runs several
action inserts as one rollback-safe unit. `atomic` defaults to `true` — if any row fails
validation, nothing is written; if a write fails mid-batch, earlier writes are rolled back
automatically (no manual reverse-rollback of a half-applied multi-write):

```js
// Insert a parent row and its children atomically — if any row is invalid, nothing is written.
return await app.runActions([
{ action: "add_order", rows: [{ id: "o-1001", customer: "Acme", total: 250 }] },
{ action: "add_line_item", rows: [
{ order_id: "o-1001", sku: "A-1", qty: 2 },
{ order_id: "o-1001", sku: "B-7", qty: 1 },
]},
], { atomic: true });
```

Success result: `{ ok: true, results: [{ action, inserted, checkpoint_id }], checkpoint_ids: [...] }`.
If a call fails validation the result names it and nothing is written.

**Derive a metric with a SQL transform.** Computed values belong in the data/SQL layer — not in
island configs or action fields. Define a `sql` transform dataset, bind islands/queries to it, then
verify the computed rows:

```js
// Derive a metric with a SQL transform instead of computing it in the agent.
await app.patchManifest({ datasets: {
daily_totals: { sql: "SELECT date_trunc('day', ts) AS day, sum(amount) AS total FROM txns GROUP BY 1 ORDER BY 1" }
}});
// bind an island or query to `daily_totals`, then verify the computed rows:
return await app.runSql({ dataset: "daily_totals" });
```

**Add a typed read (query).** Declarative, parameterized, no raw SQL:

```js
Expand Down Expand Up @@ -215,7 +247,7 @@ the user; don't try to sync. When connected, `app.runSync(name)` pulls into its
`app.listCheckpoints()`
- **write** — `app.patchManifest({ ... })`, `app.replaceManifest(manifest)`,
`app.applyEdit(proposal_id)`, `app.rollback(checkpoint_id?)`, `app.pruneCheckpoints(keep?)`
- **data** — `app.listActions()`, `app.runAction(name, rows)`
- **data** — `app.listActions()`, `app.runActions(calls, { atomic })` (atomic multi-action insert)
- **queries** — `app.listQueries()`, `app.runQuery(name, params?, { limit? })`
- **connectors** — `app.listConnectors()`, `app.runSync(name)`

Expand Down
2 changes: 1 addition & 1 deletion apps/examples/apps/health/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,6 @@ contract is computed in `models/transforms/`, never in the manifest.

Six insert actions let an agent log new rows: `log_meal` (+ `log_meal_components`),
`log_weight`, `log_workout`, and `log_panel` (+ `log_biomarkers`). Discover them with
`oi.app().listActions()`, write rows with `oi.app().runAction(...)` (inside the `execute` tool);
`oi.app().listActions()`, write rows with `oi.app().runActions(...)` (inside the `execute` tool);
every row is validated against the resolved schema before anything is written, and the views update
live. Edit `manifest.json` to change the layout; never hand-edit build output.
2 changes: 1 addition & 1 deletion apps/examples/apps/operations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ Every derived contract — the status signals, the daily deploy rollup — is co
Three insert actions let an agent record operational events: `log_incident` (open or record an
incident), `log_incident_update` (append a status update sharing the same `incident_id`), and
`log_deploy` (record a deploy). Discover them with `oi.app().listActions()`, write rows with
`oi.app().runAction(...)` (inside the `execute` tool); every row is validated against the resolved
`oi.app().runActions(...)` (inside the `execute` tool); every row is validated against the resolved
schema before anything is written, and the views update live. Edit `manifest.json` to change the
layout; never hand-edit build output.
2 changes: 1 addition & 1 deletion docs/agent-edit-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Without MCP, the same loop is: edit `manifest.json` → `openislands validate`
A manifest-declared, typed `insert` into a `source` dataset (CSV / JSON(L) and SQLite tables;
only a derived `sql` dataset is never writable). Discover with `oi.app().listActions()` (declared
actions + their resolved row JSON Schema, derived from the live data merged with the action's
`fields` overrides), then `oi.app().runAction(name, rows)` — every row is validated first; a bad row
`fields` overrides), then `oi.app().runActions([{ action, rows }])` — every row is validated first; a bad row
rejects the whole call with an error naming the row index + field and nothing is written (the result
reports the rows `inserted`). The target file is snapshotted to `.openislands/history/` before the
insert, so `rollback` covers data writes too. A SQLite-backed `source` insert is an `INSERT` into the
Expand Down
5 changes: 3 additions & 2 deletions packages/compiler/src/writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ function appendContent(existing: string, ext: string, rows: Record<string, unkno
}

function appendLines(existing: string, lines: string[]): string {
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
return `${existing}${separator}${lines.join("\n")}\n`;
const eol = existing.includes("\r\n") ? "\r\n" : "\n";
const separator = existing.length > 0 && !existing.endsWith(eol) ? eol : "";
return `${existing}${separator}${lines.join(eol)}${eol}`;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions packages/compiler/test/writers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { LocalContentStore } from "@openislands/storage";
import { resolveWriter } from "../src/writers.js";

function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "oi-writers-"));
mkdirSync(join(dir, "data"), { recursive: true });
return dir;
}

describe("appendLines EOL preservation", () => {
it("preserves CRLF when appending a row to a CRLF CSV", async () => {
const dir = tempDir();
const csvPath = "data/meals.csv";
// Seed a CSV whose every line ending is CRLF
writeFileSync(join(dir, csvPath), "name,kcal\r\nOatmeal,300\r\n");

const store = new LocalContentStore(dir);
const writer = resolveWriter(store, { dataset: "meals", source: csvPath });
await writer.insert([{ name: "Eggs", kcal: 200 }]);

const content = readFileSync(join(dir, csvPath), "utf8");
// The new row must be present
expect(content).toContain("Eggs");
// After stripping every \r\n there must be no lone \n left
expect(content.split("\r\n").join("")).not.toContain("\n");
});

it("preserves LF when appending a row to an LF CSV", async () => {
const dir = tempDir();
const csvPath = "data/meals.csv";
writeFileSync(join(dir, csvPath), "name,kcal\nOatmeal,300\n");

const store = new LocalContentStore(dir);
const writer = resolveWriter(store, { dataset: "meals", source: csvPath });
await writer.insert([{ name: "Eggs", kcal: 200 }]);

const content = readFileSync(join(dir, csvPath), "utf8");
expect(content).toContain("Eggs");
// No \r must be introduced
expect(content).not.toContain("\r");
});
});
Loading
Loading