Skip to content
Merged
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 docs/llms.txt

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/scripts/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,43 @@ whose it is, and the script's page marks the same files under Files written.
The audit event records the disposition and, for a move, how many rows it
touched.

### Who owns it and who wrote it

These are two facts about a script, and after a transfer they name two
different people. `owner_email` — on `manage_script command=get` and on each
row of `command=list` — is the person the script is filed under NOW: who sees
it, edits it, runs it, schedules it. The move above changes it.

The author does not move. Every version records the person who saved it and the
roles they held at that save, and `manage_script command=versions
name=<script>` reads that history, newest first:

```json
{
"name": "daily-sales-report",
"owner_email": "sam@example.com",
"count": 3,
"versions": [
{"version": 3, "author": "admin@example.com", "author_roles": ["admin"], "status": "applied", "created_at": "2026-08-20T09:12:00Z", "display_name": "Daily Sales", "description": "…", "category": "reporting", "tags": ["sales"]},
{"version": 2, "author": "jane@example.com", "author_roles": ["analyst"], "status": "applied", "created_at": "2026-08-14T16:04:00Z", "display_name": "Daily Sales", "description": "…", "category": "reporting", "tags": ["sales"]},
{"version": 1, "author": "jane@example.com", "author_roles": ["analyst"], "status": "applied", "created_at": "2026-08-13T14:30:00Z", "display_name": "Daily sales", "description": "…", "category": "reporting", "tags": []}
]
}
```

The oldest entry names whoever created the script, so "who wrote this?" survives
a transfer that made `owner_email` somebody else. The roles on an entry are the
authority a run of that version presents, which is why the transfer's own
version carries the administrator's roles. The same history is on the script's
page in the portal.

It carries no source. A version holds the whole body, and returning every
version's would turn one call into the complete edit history of the file; read
an earlier version's code with `command=diff`. Who may read the history is who
may read the script: its owner, and an administrator on any script. A
deployment whose store keeps no versions refuses the command rather than
answering with an empty history.

### What a run may call

A script calls the tools its author can call. `platform.query`,
Expand Down Expand Up @@ -859,6 +896,7 @@ record is readable through the tool:
| `manage_script command=runs name=daily-sales` | What has this script done lately? |
| `manage_script command=get_run run_id=…` | What did this run do, and what did it print? |
| `manage_script command=state name=daily-sales` | What will the next run read as `run.state`, and who wrote it? |
| `manage_script command=versions name=daily-sales` | Who wrote each version of this script, and with what authority? |

## State: what a run carries to the next

Expand Down
4 changes: 3 additions & 1 deletion docs/server/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -1365,7 +1365,9 @@ A script is its owner's, so a reference to one resolves for that owner and for n

`manage_script` authors, validates, and dry-runs **managed scripts**: small Starlark programs the platform stores, versions, and governs, so a process whose logic is already solved (a KPI report, a recurring export) can be re-run without deriving it again through a conversation. Write a script when the logic is settled and the work will repeat; keep using the query tools directly while you are still exploring.

**The loop.** `create` (or `update`/`patch`), then `validate`, then `run_draft`. `validate` parses and resolves the source without executing it, and reports the capabilities and connections the code references, plus a finding for any destination this deployment does not declare. `run_draft` executes it for real, under **your own identity and persona**, with tighter limits than a saved run has, persisting nothing. Both act on the `source` argument when one is sent and on the saved version when it is not, so an edit can be checked and executed before it becomes the version that runs. Commands: `create`, `update`, `patch`, `delete`, `get`, `list`, `diff`, `validate`, `run_draft`, `help`, the run-history commands `runs` and `get_run`, the state command `state` (`state_action` `get`, `set` or `clear`, the owner's and an administrator's read and reset of the one object a script carries between runs), the schedule commands `schedule_set`, `schedule_list`, `schedule_enable`, and `schedule_disable`, plus the shared content verbs (`locate`, `get_content`, `outline`, `stats`) documented in [Editing content in place](#editing-content-in-place).
**The loop.** `create` (or `update`/`patch`), then `validate`, then `run_draft`. `validate` parses and resolves the source without executing it, and reports the capabilities and connections the code references, plus a finding for any destination this deployment does not declare. `run_draft` executes it for real, under **your own identity and persona**, with tighter limits than a saved run has, persisting nothing. Both act on the `source` argument when one is sent and on the saved version when it is not, so an edit can be checked and executed before it becomes the version that runs. Commands: `create`, `update`, `patch`, `delete`, `get`, `list`, `diff`, `versions`, `validate`, `run_draft`, `help`, the run-history commands `runs` and `get_run`, the state command `state` (`state_action` `get`, `set` or `clear`, the owner's and an administrator's read and reset of the one object a script carries between runs), the schedule commands `schedule_set`, `schedule_list`, `schedule_enable`, and `schedule_disable`, plus the shared content verbs (`locate`, `get_content`, `outline`, `stats`) documented in [Editing content in place](#editing-content-in-place).

**Who owns it and who wrote it.** `owner_email`, on `get` and on each row of `list`, is the person the script is filed under now. An administrator can move a script to another owner, and that field moves with it. The author does not: every version records who saved it and the roles they held at that save, and `versions` reads that history newest first — `version`, `author`, `author_roles`, `status`, `created_at`, and the descriptive fields the snapshot carried (`display_name`, `description`, `category`, `tags`). The oldest entry names whoever created the script, so the question survives a transfer, and the roles on an entry are the authority a run of that version presents. It carries no source; read an earlier version's code with `diff`. Its visibility is `get`'s — the owner's own script, and any script for an administrator — and a deployment whose store keeps no versions refuses it rather than answering with an empty history.

**The dialect, in-context.** Call `help` before writing your first script. Starlark is Python-shaped but deliberately smaller, and `help` states exactly what is available and what a Python instinct will reach for and not find, with the corrective form for each. `help` also returns a `see_also` list naming the platform's built-in authoring pages by slug, each with the reference `fetch` reads it by, so the reasoning the contract states in outline (output identity, the choice between composing a whole document per run and refreshing one document's data region, provenance) is one call away rather than dependent on a search ranking it. `get` also retrieves seeded worked examples (`example-daily-sales`, `example-region-rollup`) so a first script starts from a script that runs. `validate` recognizes the predictable Python-isms and answers with a correction rather than a bare parse error: `import` is not available (`json` and `date` are already predeclared), `try`/`except` does not exist (an error fails the run by design; stop deliberately with `fail("why")`), f-strings are not supported (use `.format()` or `%`), `while` and recursion are off, and there is no clock, no randomness, no filesystem, and no network.

Expand Down
46 changes: 46 additions & 0 deletions internal/platform/scriptlayer/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,49 @@ func versionSource(ctx context.Context, versions script.VersionStore, history []
}
return v.Source, nil
}

// handleVersions returns the script's version history: who wrote each version
// and the authority they held, newest first.
//
// It answers a different question from get. `owner_email` is where the script
// is filed now, and an administrator can move a script to somebody else
// (#1404), so after a transfer that field names a person who may never have
// written a line of it. The author is recorded per version and does not move,
// which is why the oldest entry still names whoever created the script.
//
// The source stays out. A history entry carries the whole body, and returning
// every version's would turn one call into the complete edit history of the
// file; the body of an earlier version is read through command=diff.
func (h *Handle) handleVersions(ctx context.Context, input manageScriptInput) (*mcp.CallToolResult, any, error) {
sc, errResult := h.readable(ctx, input)
if errResult != nil {
return errResult, nil, nil
}
if h.versions == nil {
return errorResult("script versioning is unavailable on this deployment"), nil, nil
}
history, err := h.versions.ListVersions(ctx, sc.ID)
if err != nil {
slog.Error("failed to list script versions", fieldName, sc.Name, logKeyError, err)
return errorResult("failed to read the version history"), nil, nil
}
entries := make([]map[string]any, 0, len(history))
for i := range history {
entries = append(entries, versionFields(&history[i]))
}
return jsonResult(map[string]any{
fieldName: sc.Name, "owner_email": sc.OwnerEmail,
"versions": entries, "count": len(entries),
})
}

// versionFields renders one version for a history response: what was saved,
// who saved it, and the authority they held at that save.
func versionFields(v *script.Version) map[string]any {
return map[string]any{
fieldVersion: v.Version, "author": v.Author, "author_roles": orEmpty(v.AuthorRoles),
fieldStatus: v.Status, "created_at": v.CreatedAt.UTC(),
"display_name": v.DisplayName, "description": v.Description,
"category": v.Category, "tags": orEmpty(v.Tags),
}
}
105 changes: 105 additions & 0 deletions internal/platform/scriptlayer/content_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package scriptlayer

import (
"context"
"encoding/json"
"errors"
"reflect"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/txn2/mcp-data-platform/pkg/script"
"github.com/txn2/mcp-data-platform/pkg/textpatch"
)

Expand Down Expand Up @@ -230,3 +232,106 @@ func TestDiff_StoreFailureIsNotReportedAsAMissingVersion(t *testing.T) {
assert.Contains(t, resultText(res), "version history")
assert.NotContains(t, resultText(res), "not found")
}

// TestVersions_ReportsEveryAuthorNewestFirst is the history read: two applied
// versions, newest first, each naming its author and the authority that author
// held at the save.
func TestVersions_ReportsEveryAuthorNewestFirst(t *testing.T) {
h, _ := newHandle()
createDaily(t, h)
call(t, h, authorCtx(), manageScriptInput{Command: cmdUpdate, Name: "daily", Source: "print(\"changed\")\n"})

fields := resultFields(t, call(t, h, authorCtx(), manageScriptInput{Command: cmdVersions, Name: "daily"}))
assert.EqualValues(t, 2, fields["count"])
assert.Equal(t, "jane@example.com", fields["owner_email"])
versions, ok := fields["versions"].([]any)
require.True(t, ok)
require.Len(t, versions, 2)

newest, ok := versions[0].(map[string]any)
require.True(t, ok)
assert.EqualValues(t, 2, newest[fieldVersion])
assert.Equal(t, "jane@example.com", newest["author"])
assert.Equal(t, []any{"analyst"}, newest["author_roles"])
assert.Equal(t, script.VersionStatusApplied, newest[fieldStatus])
assert.NotEmpty(t, newest["created_at"])
assert.Equal(t, "Daily", newest["display_name"])
assert.NotContains(t, newest, fieldSource, "the history carries no source; diff reads a version's code")

oldest, ok := versions[1].(map[string]any)
require.True(t, ok)
assert.EqualValues(t, 1, oldest[fieldVersion])
assert.Equal(t, "jane@example.com", oldest["author"])
}

// TestVersions_SurvivesAnOwnerTransfer is the question the command exists for:
// after an administrator moves a script (#1404), owner_email names the new
// owner and the history still names the person who wrote the first version.
func TestVersions_SurvivesAnOwnerTransfer(t *testing.T) {
h, store := newHandle()
createDaily(t, h)
sc, err := store.GetByName(context.Background(), "jane@example.com", "daily")
require.NoError(t, err)
require.NotNil(t, sc)
_, err = store.Transfer(context.Background(),
script.TransferRequest{ID: sc.ID, NewOwnerEmail: "sam@example.com"},
script.Author{Email: "admin@example.com", Roles: []string{"admin"}})
require.NoError(t, err)

got := resultFields(t, call(t, h, adminCtx(), manageScriptInput{
Command: cmdGet, Name: "daily", OwnerEmail: "sam@example.com",
}))
assert.Equal(t, "sam@example.com", got["owner_email"])

fields := resultFields(t, call(t, h, adminCtx(), manageScriptInput{
Command: cmdVersions, Name: "daily", OwnerEmail: "sam@example.com",
}))
assert.Equal(t, "sam@example.com", fields["owner_email"])
versions, ok := fields["versions"].([]any)
require.True(t, ok)
require.Len(t, versions, 2)
first, ok := versions[len(versions)-1].(map[string]any)
require.True(t, ok)
assert.EqualValues(t, 1, first[fieldVersion])
assert.Equal(t, "jane@example.com", first["author"], "the author of the first version does not move with the script")
}

// TestVersions_NotTheOwnerGetsTheGetRefusal holds the history to the visibility
// get already applies, in the same words: naming the difference would confirm
// the script exists to somebody who may not see it.
func TestVersions_NotTheOwnerGetsTheGetRefusal(t *testing.T) {
h, _ := newHandle()
createDaily(t, h)
stranger := callerCtx("sam@example.com", "analyst")

versionsRes := call(t, h, stranger, manageScriptInput{Command: cmdVersions, Name: "daily"})
getRes := call(t, h, stranger, manageScriptInput{Command: cmdGet, Name: "daily"})
assert.True(t, versionsRes.IsError)
assert.Equal(t, resultText(getRes), resultText(versionsRes))
}

// TestVersions_WithoutAVersionStoreRefusesLikeDiff covers the degraded
// deployment: no versioning means no history, refused in the terms diff
// already refuses in rather than answered with an empty list.
func TestVersions_WithoutAVersionStoreRefusesLikeDiff(t *testing.T) {
h := New(Config{Store: &unversionedStore{inner: newMemStore()}, AdminPersona: "admin"})
require.Nil(t, h.versions)
createDaily(t, h)

res := call(t, h, authorCtx(), manageScriptInput{Command: cmdVersions, Name: "daily"})
diffRes := call(t, h, authorCtx(), manageScriptInput{Command: cmdDiff, Name: "daily"})
assert.True(t, res.IsError)
assert.Equal(t, resultText(diffRes), resultText(res))
}

// TestVersions_StoreFailureIsReportedAsSuch keeps a store that blinked from
// reading as a script with no history.
func TestVersions_StoreFailureIsReportedAsSuch(t *testing.T) {
h, store := newFailingHandle()
createDaily(t, h)
store.listVersionsErr = errors.New("pq: connection reset")

res := call(t, h, authorCtx(), manageScriptInput{Command: cmdVersions, Name: "daily"})
require.True(t, res.IsError)
assert.Contains(t, resultText(res), "version history")
}
17 changes: 15 additions & 2 deletions internal/platform/scriptlayer/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ const manageScriptDescription = "Author, validate, and dry-run managed scripts:
"reports what the script would reach, runs nothing), then run_draft (executes for real under YOUR " +
"identity and persona, with tighter limits, persisting nothing). " +
"A saved script runs: run_script executes its latest saved version as the script's own principal, " +
"presenting the roles you held when you saved it, and a schedule fires it the same way."
"presenting the roles you held when you saved it, and a schedule fires it the same way. " +
"command=versions reads the history of who wrote each version, which is not the same question as " +
"who owns the script now."

// DialectContract is the help command's body: what a script is, what is
// predeclared, and what a Python instinct will reach for and not find.
Expand Down Expand Up @@ -233,7 +235,18 @@ THE LOOP
A draft reads the script's live state and reports what it would have saved;
manage_script command=state reads, replaces or clears the state itself, which
is how a wrong watermark is corrected: clear it and let the next run start
over.`
over.

WHO OWNS IT AND WHO WROTE IT
These are two facts, and two commands answer them. get reports owner_email,
the person the script is filed under NOW; an administrator can move a script
to somebody else, and the address moves with it. command=versions reports the
history newest first — the version number, its author, the roles that author
held at that save, the status, when it was written, and what the script
called itself then. The roles are the authority a run of that version
presents, and the oldest entry names whoever created the script, so a
transfer never loses the author. It carries no source; read an earlier
version's code with command=diff.`

// example is one built-in worked script, retrievable by name through get.
type example struct {
Expand Down
12 changes: 9 additions & 3 deletions internal/platform/scriptlayer/scriptlayer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ func (m *memStore) Create(_ context.Context, sc *script.Script, author script.Au
func (m *memStore) snapshot(sc *script.Script, author script.Author, status string) {
m.versions[sc.ID] = append(m.versions[sc.ID], script.Version{
ID: fmt.Sprintf("sver_%s_%d", sc.ID, sc.Version), ScriptID: sc.ID, Version: sc.Version,
DisplayName: sc.DisplayName, Description: sc.Description, Source: sc.Source,
DisplayName: sc.DisplayName, Description: sc.Description, Category: sc.Category,
Source: sc.Source,
Params: sc.Params, Tags: sc.Tags, Author: author.Email, AuthorRoles: author.Roles,
Status: status,
Status: status, CreatedAt: time.Now().UTC(),
})
}

Expand Down Expand Up @@ -235,8 +236,13 @@ func (m *memStore) UpdateWithVersion(ctx context.Context, sc *script.Script, aut
return m.Update(ctx, sc)
}

// ListVersions answers newest first, as the PostgreSQL store's ORDER BY
// version DESC does. The fake appends in save order, so it reverses on the way
// out rather than leaving the ordering half of the contract to the caller.
func (m *memStore) ListVersions(_ context.Context, scriptID string) ([]script.Version, error) {
return slices.Clone(m.versions[scriptID]), nil
history := slices.Clone(m.versions[scriptID])
slices.Reverse(history)
return history, nil
}

func (m *memStore) GetVersionByID(_ context.Context, id string) (*script.Version, error) {
Expand Down
4 changes: 3 additions & 1 deletion internal/platform/scriptlayer/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
cmdOutline = "outline"
cmdStats = "stats"
cmdDiff = "diff"
cmdVersions = "versions"
cmdRuns = "runs"
cmdGetRun = "get_run"
cmdState = "state"
Expand Down Expand Up @@ -167,6 +168,7 @@ func (h *Handle) commands() map[string]commandHandler {
cmdOutline: h.handleOutline,
cmdStats: h.handleStats,
cmdDiff: h.handleDiff,
cmdVersions: h.handleVersions,
cmdRuns: h.handleRuns,
cmdGetRun: h.handleGetRun,
cmdState: h.handleState,
Expand Down Expand Up @@ -267,7 +269,7 @@ func manageScriptSchema() any {
keyEnum: []string{
cmdCreate, cmdUpdate, cmdDelete, cmdGet, cmdList, cmdValidate,
cmdRunDraft, cmdHelp, cmdPatch, cmdLocate, cmdGetContent,
cmdOutline, cmdStats, cmdDiff, cmdRuns, cmdGetRun, cmdState,
cmdOutline, cmdStats, cmdDiff, cmdVersions, cmdRuns, cmdGetRun, cmdState,
cmdScheduleSet, cmdScheduleList, cmdScheduleEnable, cmdScheduleDisable,
},
keyDescription: "The operation to perform. Call 'help' first if you have not written a " +
Expand Down
Loading
Loading