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
26 changes: 21 additions & 5 deletions docs/contracts/runtime-host-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,34 @@ result of the operation being logged: the Runtime logger catches Host errors and
counts them (`runtime:log_stats()`). Records contain ids, versions, sizes and
error codes, never document bytes or file paths.

## 6. Optional atomic-save service
## 6. Optional filesystem services

`host.fs` may provide:
`host.fs` may provide the following optional methods. They are not required
during Runtime startup; commands that need an absent method fail closed with
`UNSUPPORTED`. File I/O remains Backend-owned and Runtime never opens native
files itself.

- `read_file(path, { max_bytes? }) -> byte-string`
- `begin_atomic_write(path, options) -> writer_handle`
- `write_chunk(writer_handle, bytes) -> true`
- `commit_atomic_write(writer_handle) -> true`
- `abort_atomic_write(writer_handle) -> true`

The runtime writes a version-pinned snapshot in bounded chunks, commits the
atomic replacement, then calls `mark_clean_if_version`. This service is optional
at startup but required for the built-in `document.save` command.
The read service returns exact file bytes, including NUL, invalid UTF-8, BOM
and original line terminators. It must enforce the supplied positive `max_bytes`
limit and the Backend's configured document-size ceiling before returning a Lua
string. It must not return a partial file when a limit or I/O error is reached.
A missing read service does not block Runtime startup; the built-in
`document.open` command returns `UNSUPPORTED`. Missing paths use `NOT_FOUND`,
size violations use `LIMIT_EXCEEDED`, and operating-system failures use
`IO_ERROR`; Runtime preserves these structured identities for callers.

The save path writes a version-pinned Kernel snapshot in bounded chunks,
commits the atomic replacement, then calls `mark_clean_if_version`. On any
write/commit error, Runtime aborts the temporary writer and closes the snapshot
lease; the previously published destination must remain unchanged. The write
service is optional at startup but required for the built-in `document.save`
command.

## 7. Kernel ABI mapping

Expand Down
37 changes: 31 additions & 6 deletions lua/cdin/runtime/application.lua
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ function Runtime:save_document(id, path, options)
return result
end

function Runtime:close_document(id)
function Runtime:_close_document(id, publish)
self:_assert_running()
self.documents:get(id) -- Fail before mutating the UI for an unknown identifier.
-- Detach views first so a close error cannot leave UI references to a dead handle.
Expand All @@ -177,14 +177,18 @@ function Runtime:close_document(id)
self.ui:remove_view(view_id)
end
local ok, result = pcall(self.documents.close, self.documents, id)
self:_publish_ui()
if publish ~= false then self:_publish_ui() end
if not ok then
error(errors.normalize(result, "INTERNAL", "close document"), 0)
end
return result
end

function Runtime:create_view(document_id, options)
function Runtime:close_document(id)
return self:_close_document(id, true)
end

function Runtime:_create_view(document_id, options, publish)
self:_assert_running()
self.documents:get(document_id)
if options ~= nil and type(options) ~= "table" then
Expand All @@ -197,10 +201,14 @@ function Runtime:create_view(document_id, options)
document_id = document_id,
})
self.ui:add_view(view, options)
self:_publish_ui()
if publish ~= false then self:_publish_ui() end
return view
end

function Runtime:create_view(document_id, options)
return self:_create_view(document_id, options, true)
end

function Runtime:focused_view() return self.ui:focused_view() end
function Runtime:focused_document()
local view = self:focused_view()
Expand Down Expand Up @@ -325,6 +333,16 @@ function Runtime:_publish_ui()
return self.host.ui:publish(snapshot)
end

-- Lifecycle commands may return the view they created. Runtime applies that
-- semantic focus transition centrally before the single final UI publication.
-- Frontends and transport handlers never infer focus from command names.
function Runtime:_focus_command_result(result)
if type(result) == "table" and type(result.view_id) == "string"
and self.ui.views[result.view_id] ~= nil and self.ui.focus ~= result.view_id then
self.ui:set_focus(result.view_id)
end
end

function Runtime:dispatch(name, args, context)
self:_assert_running()
local supplied = context or {}
Expand All @@ -334,7 +352,10 @@ function Runtime:dispatch(name, args, context)
context.document = context.document or self:focused_document()
context.view = context.view or self:focused_view()
local ok, result = self.commands:execute(name, context, args or {})
if ok then self:_publish_ui() end
if ok then
self:_focus_command_result(result)
self:_publish_ui()
end
return ok, result
end

Expand All @@ -343,7 +364,11 @@ function Runtime:_dispatch_chain(names)
for _, name in ipairs(names or {}) do
if self.commands:can_execute(name, context, {}) then
local ok, result = self.commands:execute(name, context, {})
if ok then self:_publish_ui(); return { status = "executed", command = name, result = result } end
if ok then
self:_focus_command_result(result)
self:_publish_ui()
return { status = "executed", command = name, result = result }
end
end
end
return { status = "unhandled", commands = names or {} }
Expand Down
70 changes: 70 additions & 0 deletions lua/cdin/runtime/command/defaults.lua
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,76 @@ function M.register(runtime)
end, {
description = "Replace a byte range through the document Host API",
})

commands:register("document.new", function(_, args)
local initial_bytes = args.initial_bytes
if initial_bytes == nil then initial_bytes = "" end
if type(initial_bytes) ~= "string" then
errors.raise("INVALID_ARGUMENT", "document.new initial_bytes must be a byte string")
end
if args.path ~= nil and (type(args.path) ~= "string" or args.path == "") then
errors.raise("INVALID_ARGUMENT", "document.new path must be a non-empty string")
end
local document = runtime:open_document(initial_bytes, { path = args.path })
local ok, view = pcall(runtime._create_view, runtime, document.id, nil, false)
if not ok then
pcall(runtime.close_document, runtime, document.id)
error(errors.normalize(view, "INTERNAL", "create document view"), 0)
end
return { document_id = document.id, view_id = view.id, version = document.version }
end, {
description = "Create an empty or byte-initialized document and focus its first view",
})

commands:register("document.open", function(_, args)
if type(args.path) ~= "string" or args.path == "" then
errors.raise("INVALID_ARGUMENT", "document.open requires a non-empty path")
end
local fs = runtime.host.fs
if type(fs) ~= "table" or type(fs.read_file) ~= "function" then
errors.raise("UNSUPPORTED", "Backend Host does not provide bounded file reads")
end
local bytes = fs:read_file(args.path, { max_bytes = args.max_bytes })
if type(bytes) ~= "string" then
errors.raise("INTERNAL", "Backend Host file read did not return a byte string")
end
local document = runtime:open_document(bytes, { path = args.path })
local ok, view = pcall(runtime._create_view, runtime, document.id, nil, false)
if not ok then
pcall(runtime.close_document, runtime, document.id)
error(errors.normalize(view, "INTERNAL", "create opened document view"), 0)
end
runtime.log:info("document opened", {
document_id = document.id,
version = document.version,
bytes = document.length,
})
return { document_id = document.id, view_id = view.id, version = document.version }
end, {
description = "Open exact file bytes through the optional Backend Host filesystem service",
})

commands:register("document.close", function(context)
local document = active_document(runtime, context)
if not document then
return false, "no active document"
end
return runtime:_close_document(document.id, false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check dirty state before closing a document

When a document has unsaved edits, invoking this new command (including the newly bound Ctrl+W) unconditionally closes its Kernel handle and removes its views; no dirty-state check or confirmation runs, so the only canonical copy of those edits is discarded without a save/cancel choice. Guard dirty closes with a confirmation or explicit discard path rather than closing immediately.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.

end, {
description = "Close the focused document and remove its views",
available = function(context)
return context.document ~= nil or runtime:focused_document() ~= nil
end,
})

-- FE-016 / PROTO-041: default editing shortcuts are Runtime policy. The
-- Frontend emits raw key events and never duplicates or interprets bindings.
runtime.keymap:bind("ctrl+n", "document.new", { owner = "core" })
runtime.keymap:bind("ctrl+s", "document.save", { owner = "core" })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface a missing save capability on Ctrl+S

For a path-backed document on a Host without begin_atomic_write, document.save is filtered out by its available predicate before Saver:save can raise UNSUPPORTED, so this new Ctrl+S binding returns unhandled instead of the documented unsupported-capability error. Keep the command executable when the writer is absent so it can return UNSUPPORTED, or surface that typed result from the binding.

Useful? React with 👍 / 👎.

runtime.keymap:bind("ctrl+z", "document.undo", { owner = "core" })
runtime.keymap:bind("ctrl+shift+z", "document.redo", { owner = "core" })
runtime.keymap:bind("ctrl+w", "document.close", { owner = "core" })

end

return M
1 change: 1 addition & 0 deletions lua/cdin/runtime/core/error.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ M.codes = {
BUFFER_TOO_SMALL = true,
VERSION_MISMATCH = true,
INTERNAL = true,
IO_ERROR = true,
HOST_API_MISMATCH = true,
BAD_EXTENSION = true,
DUPLICATE_REGISTRATION = true,
Expand Down
20 changes: 20 additions & 0 deletions lua/cdin/runtime/core/version.lua
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,27 @@ local function git_command(arguments)
end

local function from_git()
local root = repository_root()
if not root then
return nil
end

local redirect = command_error_redirect()
local git_top = read_command(git_command("rev-parse --show-toplevel") .. redirect)
if not git_top then
return nil
end

-- Installed Runtime modules may live inside another Git checkout (for
-- example, CDIN's share/cdin/lua tree). Git walks upward from -C and would
-- otherwise report the host application's version as the Runtime version.
-- An empty --show-prefix means root is the top-level of the Git repository;
-- a non-empty prefix means this is only a nested installation directory.
local git_prefix = read_command(git_command("rev-parse --show-prefix") .. redirect)
if git_prefix ~= nil then
return nil
end

local describe = read_command(
git_command('describe --tags --long --dirty --match "v[0-9]*"') .. redirect
)
Expand Down
Loading
Loading