Help us shape the future of Go! Take the 2026 Go Developer Survey

phpscript

package
v0.0.0-...-284f467 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

Documentation

Overview

Package phpscript is the plugin boundary: a module is a directory with a modinfo.php in it, run by the phpscript interpreter the binary holds, served at /m/<vendor>/<name> as the body of an ordinary task-ui page.

A Manager finds the modules under its roots, runs each modinfo.php once to learn what it registers, and keeps a pool of runtimes per module to serve requests from. Four host symbols reach the PHP: the Task\Module constructor a modinfo.php builds, Task::register, which records it, Task::migrate, which applies the module's own schema, and Task::dispatch, which starts a Claude run. Which module is calling is not an argument of any of them; it is on the runtime's context, so the four bindings are the same for every module.

The handler rewrites nothing. $_SERVER["REQUEST_URI"] holds the path the browser requested, the /m/<vendor>/<name> prefix included, and a module builds its links from what it reads. Writing one is docs/modules.md.

Index

Constants

View Source
const (
	Public = "public"
	Index  = "index.php"
)

Public is the directory of a module served as bytes, and Index is what answers every path it does not hold.

View Source
const ArtifactPrefix = "/artifacts/files/"

ArtifactPrefix is where a relative link in a module's markdown resolves: the instance's artifact tree, which is where a run wrote the files its report names.

View Source
const Connection = "task-ui"

Connection is the name a module opens the instance's database under: `new Database("task-ui")`. There is one, and every module shares it, because a module's tables are task-ui's tables in task-ui's database.

View Source
const DefaultTimeout = 30 * time.Second

DefaultTimeout bounds one render. phpscript holds a TimeLimit option and enforces none of it, so the bound is kept here: a module that loops forever ends with the page and leaves the instance alone.

View Source
const ModInfo = "modinfo.php"

ModInfo is the file a module is found by.

View Source
const ModuleRule = "module"

ModuleRule is the permission a module's sidebar row needs. Every module shares it, so granting a role the modules grants every module: a plugin is installed by putting its directory under a root, and a rule per module would be a rule nobody wrote down.

View Source
const Prefix = "/m"

Prefix is where every module is served from. A path under it names the module in its first two segments and belongs to the module in the rest.

View Source
const ProjectLimit = 16

ProjectLimit is how long a migration project name may be: the column mig records one in holds sixteen characters.

View Source
const ReadLimit = 256 << 10

ReadLimit is the most a module reads out of one artifact. A report is prose and a schema is a few kilobytes; anything past this is a file to link rather than a file to render, and the page links it either way.

Variables

View Source
var ErrEscapes = errors.New("the path leaves the module root")

ErrEscapes is returned when a module names a path outside its own root.

View Source
var ErrName = errors.New("a module name is vendor/plugin")

ErrName is returned when a module's name is not <vendor>/<plugin>.

View Source
var ErrNoModule = errors.New("no module on the context")

ErrNoModule is returned when a binding runs on a runtime the Manager did not set a module on.

View Source
var ErrNotFound = errors.New("no module there")

ErrNotFound is returned when the path names no module, or a path under a module that its public tree does not hold and that index.php answered 404 for.

Functions

func ArtifactHref

func ArtifactHref(folder string) string

ArtifactHref is the link a path inside one folder of the tree reads as, the whole tree when the folder is empty. A report written inside a folder names its files relative to that folder, and the page has to put the folder back.

func Bind

func Bind(rt *runner.Runtime, opts Options, registered func(*Module))

Bind registers the host symbols of a module's runtime: the `Task\Module` constructor, and `Task::register`, `Task::migrate` and `Task::dispatch` as host functions whose names hold `::`. registered takes what a modinfo.php registers, and nil refuses the call.

The context.Context each binding takes first is the interpreter's and names the module; PHP counts it as no argument. docs/modules.md is the contract and docs/todo-correction.md says why the constructor is not `Task::Module`.

func Inside

func Inside(root, path string) (string, error)

Inside returns path as a path relative to root, and refuses one that leaves it. A module reads its own directory: a `..` segment, an absolute path and a symlink pointing out all come back as an error.

func IsImage

func IsImage(rel string) bool

IsImage reports whether a path is one the page embeds rather than links, by its extension: a schema diagram is read on the page and a SQL file is a link.

func Observe

func Observe(rt *runner.Runtime, tracer *oida.Tracer)

Observe points a runtime's telemetry at the instance's own tracer, so one trace in /debug/oida holds the request and the PHP that ran under it. phpscript's telemetry package aliases the same oida, so the tracer passes through unwrapped. A nil tracer records nothing.

func ParseName

func ParseName(name string) (string, string, error)

ParseName splits a module's name into its vendor and its plugin.

Both halves are lower case, and each one holds letters, digits, a dash, an underscore or a dot: the name reaches a URL, a table row and a directory, and these are the characters all three read the same. A name with a slash too few or too many, an empty half or a dot segment is refused.

func Route

func Route(urlPath string) (string, string, error)

Route splits a request path into the module's name and what is left for the module to answer. A path with fewer than two segments after the prefix names no module.

func Startups

func Startups(root string) ([]string, error)

Startups returns the module's other @startup files, by path, without its modinfo.php: that one runs first and on its own, because nothing else may run before a module has registered.

A module puts its migrations in one of these rather than requiring the file from modinfo.php, so a startup step is a file with an annotation and the list of them is the directory.

func WithModule

func WithModule(ctx context.Context, m *Module) context.Context

WithModule returns a context naming the module whose runtime is about to run. A binding reads the module there and takes no argument for it.

Types

type Database

type Database struct {
	DB *sqlx.DB
}

Database is what resolves the connection a module's PHP opens. It answers one name with the instance's own pool and refuses the rest, so a module reaches the database it migrated into and no other.

The pool is the instance's: sqlite on one connection. A module holding a transaction open holds the instance's only connection with it, which is why a module's work belongs in a statement and not in a long transaction.

func NewDatabase

func NewDatabase(db *sqlx.DB) *Database

NewDatabase returns the provider over a pool. A nil pool answers every name with an error, which is an instance whose modules store nothing.

func (*Database) Connect

func (d *Database) Connect(ctx context.Context, names ...string) (*sqlx.DB, error)

Connect is Open plus a ping, which is what the Database constructor calls: the ping proves the storage reachable before a script sees a client.

func (*Database) Open

func (d *Database) Open(_ context.Context, names ...string) (*sqlx.DB, error)

Open returns the pool for the first name it knows.

type Dispatcher

type Dispatcher interface {
	Dispatch(ctx context.Context, m *Module, comment, action, folder string) (string, error)
}

Dispatcher starts a Claude run for a module. `Task::dispatch` reaches it with the comment a user wrote and the path of a markdown file under the module's root; the prompt is that file, then the comment, then the run's own environment, the artifact tree among it.

The folder is where in the artifact tree the run writes, and the only part of the tree the prompt names. A module that gives each of its threads a folder passes it here and the run gets that folder to itself; empty is the top of the tree, which every run shares.

It returns the identifier of what it started, which the module records beside the comment, so a page links the run that answered it.

type Found

type Found struct {
	Root    string
	File    string
	Startup bool
}

Found is a modinfo.php a walk reached, and the directory holding it, which is the module's root. Startup reports whether the file holds the @startup annotation; a file without it is not run, and the Manager says so.

func Discover

func Discover(roots ...string) ([]Found, error)

Discover walks each root for a modinfo.php and returns what it found, by path, so two starts register the modules in the same order.

A root that is not there is not an error: an instance with no modules directory yet has no modules, which is the state of a fresh data folder before the embedded tree is extracted. A root it cannot read is.

vendor, node_modules and .git are never descended into, so a composer dependency cannot publish a module of its own into the instance.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager holds the modules of the instance: what Discover found, what each modinfo.php registered, and a pool of runtimes per module to serve requests from. One Manager serves the process.

A nil Manager answers as an instance with no modules, so a handler needs no check of its own.

func New

func New(opts Options) *Manager

New returns the Manager of opts. Nothing is read yet: Start walks the roots.

func (*Manager) Module

func (m *Manager) Module(name string) (*Module, bool)

Module returns the module of a name, and whether the instance has it.

func (*Manager) Modules

func (m *Manager) Modules() []Module

Modules returns the registered modules, by name.

func (*Manager) Reload

func (m *Manager) Reload(ctx context.Context) error

Reload drops every module and reads the roots again, so a module added to a checkout appears without a restart.

func (*Manager) Render

func (m *Manager) Render(ctx context.Context, w http.ResponseWriter, r *http.Request) (Result, error)

Render answers one request against a module: a file under its public directory as bytes, and anything else as whatever index.php echoed.

The request is not rewritten, so $_SERVER["REQUEST_URI"] holds the path the browser requested, prefix and all.

func (*Manager) Start

func (m *Manager) Start(ctx context.Context) error

Start walks the roots, runs every modinfo.php once and records what each registered. It is the whole of a module's registration: nothing else runs a modinfo.php, and a module that did not register here does not serve.

A module that throws, registers nothing or names itself wrongly is reported through OnError and left out; the modules that did register serve. A modinfo.php the server cannot read is the one failure that comes back, because an instance that cannot read a file it found is misconfigured rather than empty. A root that is not there, or that holds no module, is neither.

func (*Manager) Timeout

func (m *Manager) Timeout() time.Duration

Timeout is how long one render may run, which a handler needs to bound the request it passes in.

type Migrator

type Migrator interface {
	Migrate(ctx context.Context, m *Module, dir string) error
}

Migrator applies a module's own schema into task-ui's database. The Manager holds one and `Task::migrate` reaches it, so the SQL a module ships is applied by the same mig the instance's own migrations are.

dir is relative to the module's root, and a path leaving the root is refused: a module migrates its own schema and no other.

type Module

type Module struct {
	// Vendor and Plugin are the two halves of the name, as SetName split it.
	Vendor string
	Plugin string

	// Root is the directory holding the modinfo.php, absolute.
	Root string

	// Title, Description and Icon are the sidebar row and the module's page
	// heading. Icon names a symbol of the sprite without the i- prefix.
	Title       string
	Description string
	Icon        string

	// Version is whatever the module calls its own, for the module table.
	Version string
}

Module is what a modinfo.php registers: the name that decides its link and its row in the module table, the directory holding it, and what the sidebar shows. A PHP script builds one with `new Task\Module` and fills it through the setters, so every field has one.

Root is not a setter. The Manager sets it to the directory the modinfo.php sits in, so a module cannot claim a directory it does not live in.

func ModuleFrom

func ModuleFrom(ctx context.Context) (*Module, bool)

ModuleFrom returns the module of the context, and whether one is there. A context without one is a binding the Manager did not set up, which it refuses.

func NewModule

func NewModule(_ context.Context) (*Module, error)

NewModule returns the empty module `new Task\Module` builds, which names itself through the setters. The context is the interpreter's.

func (*Module) Href

func (m *Module) Href() string

Href is the link the sidebar row points at.

func (*Module) Label

func (m *Module) Label() string

Label is the title, falling back to the plugin half of the name, so a module that set none still reads as something in the sidebar.

func (*Module) Name

func (m *Module) Name() string

Name is the module's name, <vendor>/<plugin>, and the unique key of its row.

func (*Module) Project

func (m *Module) Project() string

Project is the module's name as mig records an applied migration under. Each module migrates under its own, so two modules cannot claim one applied row and a module added later starts from its own first migration rather than from wherever the instance stands.

A full name does not fit: `titpetric/refinement` is twenty characters and the column holds sixteen. The name is the plugin cut to seven characters, then eight hex of the sha256 of the whole name, so `titpetric/refinement` reads as `refinem_` and its digest. The prefix says which module a row belongs to at a glance; the digest is what keeps two vendors' modules of one name apart.

func (*Module) SetDescription

func (m *Module) SetDescription(description string)

SetDescription sets the line under the heading.

func (*Module) SetIcon

func (m *Module) SetIcon(icon string)

SetIcon sets the sprite symbol of the sidebar row, without the i- prefix.

func (*Module) SetName

func (m *Module) SetName(name string) error

SetName splits <vendor>/<plugin> into the two halves and refuses anything else. It is the one setter that can fail, so a modinfo.php that names itself wrongly throws where it wrote the name.

func (*Module) SetTitle

func (m *Module) SetTitle(title string)

SetTitle sets the title of the sidebar row and the page heading.

func (*Module) SetVersion

func (m *Module) SetVersion(version string)

SetVersion sets what the module calls its own version.

func (*Module) Validate

func (m *Module) Validate() error

Validate reports what keeps a module out of the table: a name it never set, and a root the Manager could not fill.

type Options

type Options struct {
	// Roots are the directories walked for a modinfo.php, in order. The first
	// is the instance's own modules directory, where the embedded tree is
	// extracted; a workspace root after it reaches a module in a checkout.
	Roots []string

	// Registry records what a modinfo.php registered. Nil runs the modules
	// and keeps them in memory alone, as a test does.
	Registry Registry

	// Migrator applies a module's schema. Nil makes Task::migrate refuse.
	Migrator Migrator

	// Dispatcher starts the Claude runs. Nil makes Task::dispatch refuse.
	Dispatcher Dispatcher

	// Runs answers which run a dispatch turned into, for the status
	// label a module draws. Nil answers "" for every note.
	Runs Runs

	// Database resolves the connection a module opens, `new Database("task-ui")`.
	// Nil leaves a module with no database.
	Database *Database

	// Artifacts is the instance's artifact tree as a module reads it.
	// Nil leaves Task::artifacts empty and Task::artifact blank.
	Artifacts *Tree

	// Tracer records the spans of the PHP into the same traces as the
	// instance's own middleware. Nil records nothing.
	Tracer *oida.Tracer

	// PoolSize is how many registered runtimes a module keeps between
	// requests. One runtime serves one goroutine at a time, so this is also
	// the cheapest bound on how many renders run at once.
	PoolSize int

	// Timeout bounds one render.
	Timeout time.Duration

	// Version is the instance's version, which PHP reads as Task::VERSION.
	Version string

	// OnError receives what the Manager cannot report through a return: a
	// modinfo.php that threw, a module that registered nothing, a template
	// directory it could not seed. Nil drops them.
	OnError func(error)
}

Options configures the Manager. Take NewOptions and override what the instance needs, so a field added later keeps its default.

func NewOptions

func NewOptions(root string) Options

NewOptions returns the options of an instance serving the modules under root.

type Registry

type Registry interface {
	// Register upserts a module that passed Validate. A module already in the
	// table keeps its row, its identifier and its place in the sidebar, and
	// takes the rest from the modinfo.php just read.
	Register(ctx context.Context, m *Module) error

	// Forget takes the names of every module a scan found and drops the rows
	// of the others, whose directories are gone. It returns how many.
	Forget(ctx context.Context, found []string) (int, error)
}

Registry is where a module lands once its modinfo.php registered it: the module table, and the sidebar row that links it.

type Result

type Result struct {
	// Body is what the script echoed, or the bytes of a file under public/.
	Body []byte

	// Status is what the script staged, 200 when it staged none.
	Status int

	// Header holds the headers the script staged. Content-Type is set for a
	// file served from public/ and left to the layout for a rendered page.
	Header http.Header

	// Took is how long the render ran, which the page footer prints.
	Took time.Duration

	// File reports a path under the module's public/ tree, served as bytes
	// with no layout around it.
	File bool
}

Result is what one render produced. The body reaches the response unwritten, because a module's output is the body of a task-ui page and the layout goes around it; a handler that serves a file writes Body as it stands.

type Runs

type Runs interface {
	RunOf(ctx context.Context, note string) string
}

Runs answers which run a module's dispatch turned into. `Task::run` reaches it with the note identifier a dispatch returned.

A dispatch opens a note and starts the run behind it, in that order, so the run does not exist yet when the dispatch answers. The module records the note and asks for the run when it draws a page, which is later and by then there is one.

It answers "" for a note with no run yet, and for no note at all. A page draws a label either way; an empty one is a run that has not started.

type Schema

type Schema struct {
	DB storage.DB
}

Schema applies a module's own schema into task-ui's database. It is what `Task::migrate("schema/")` reaches.

The module declares the SQL and the instance applies it, through the same mig the instance's own migrations go through, so a module's tables are task-ui's tables and `mig docs` reads them beside the rest.

Each module migrates under a project of its own name, so two modules cannot claim one applied row and a module added later starts from its own first migration rather than from wherever the instance stands.

func NewSchema

func NewSchema(db storage.DB) *Schema

NewSchema returns the Migrator over a pool.

func (*Schema) Migrate

func (s *Schema) Migrate(ctx context.Context, module *Module, dir string) error

Migrate applies the *.up.sql of a directory under the module's root.

A directory that is not there is not an error: a module with no schema stores nothing, and a migrate.php in a module that has none yet should not keep it from registering.

type Store

type Store struct {
	Modules *storage.ModuleStorage
	Sidebar *storage.SidebarStorage
}

Store is the Registry over the database: a module lands in the module table and gets a row in the sidebar, and a module a scan no longer finds loses both.

func NewStore

func NewStore(modules *storage.ModuleStorage, sidebar *storage.SidebarStorage) *Store

NewStore returns the Registry over the two tables.

func (*Store) Forget

func (s *Store) Forget(ctx context.Context, found []string) (int, error)

Forget drops the module rows and the sidebar rows of every module the scan did not find, and returns how many rows went.

func (*Store) Register

func (s *Store) Register(ctx context.Context, m *Module) error

Register records a module and the sidebar row that links it.

The row keeps its position and its hidden flag, so a module registering on every start does not walk to the end of the sidebar, and one the user hid stays hidden. The title and the icon are the modinfo.php's, so editing that file and restarting is how a module renames its own row.

type Tree

type Tree struct {
	Store *artifactstore.Store
}

Tree is the instance's artifact tree as a module reads it: the files its runs left, and the text of one.

A module cannot reach the tree on its own. The filesystem boundary roots every path a script opens at the module's own directory, which is the point: a plugin reads its own files and the artifacts through a binding that knows what the artifacts are.

Reading only. A module shows what a run wrote; the runs write.

func NewTree

func NewTree(store *artifactstore.Store) *Tree

NewTree returns the reader over the instance's artifact tree. A nil store is an instance with none, and both calls answer empty.

func (*Tree) Files

func (t *Tree) Files(folder string) []string

Files is every file under one folder of the tree, by path relative to that folder, as a module lists them. An empty folder is the whole tree. Directories are walked and not listed: a module shows files.

The folder is how a module keeps its own scratch space apart from the instance's: a plugin that gives each of its threads a folder lists one thread's files by naming it, and the paths come back the way the run wrote them, without the folder in front.

func (*Tree) Read

func (t *Tree) Read(rel string) string

Read is the text of one artifact, and an empty string for a path that is not there, is a directory, or holds more than ReadLimit.

The path is read from the top of the tree. A path leaving it is refused the way a module's own paths are.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL