diff --git a/docs/contracts/runtime-host-api.md b/docs/contracts/runtime-host-api.md index b4243ec..7dc4c2c 100644 --- a/docs/contracts/runtime-host-api.md +++ b/docs/contracts/runtime-host-api.md @@ -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 diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index 443c900..ce495b7 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -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. @@ -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 @@ -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() @@ -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 {} @@ -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 @@ -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 {} } diff --git a/lua/cdin/runtime/command/defaults.lua b/lua/cdin/runtime/command/defaults.lua index 845ea9c..550c2b9 100644 --- a/lua/cdin/runtime/command/defaults.lua +++ b/lua/cdin/runtime/command/defaults.lua @@ -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) + 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" }) + 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 diff --git a/lua/cdin/runtime/core/error.lua b/lua/cdin/runtime/core/error.lua index 1008d88..dcad8b7 100644 --- a/lua/cdin/runtime/core/error.lua +++ b/lua/cdin/runtime/core/error.lua @@ -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, diff --git a/lua/cdin/runtime/core/version.lua b/lua/cdin/runtime/core/version.lua index 939a188..0b2fd84 100644 --- a/lua/cdin/runtime/core/version.lua +++ b/lua/cdin/runtime/core/version.lua @@ -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 ) diff --git a/tests/integration/document_lifecycle_commands.lua b/tests/integration/document_lifecycle_commands.lua new file mode 100644 index 0000000..d7caee4 --- /dev/null +++ b/tests/integration/document_lifecycle_commands.lua @@ -0,0 +1,195 @@ +local A = require("support.assert") +local FakeHost = require("support.fake_host") +local Runtime = require("cdin.runtime") + +local path = "workspace/binary-document.cdin" +local original = "\239\187\191A\0B\255\r\nline-two\nline-three\r" +local after_text = "\239\187\191X" .. original:sub(4) +local expected = "\239\187\191X\0P\255" .. original:sub(4) + +local host = FakeHost.new() +host.fs:write_test_file(path, original) +local runtime = Runtime.new({ host = host }) +runtime:start() + +-- request.command -> Runtime -> optional Backend Host fs read -> Kernel document. +local opened = runtime:handle_frontend_event({ + type = "request.command", + name = "document.open", + arguments = { path = path }, +}) +A.equal(opened.status, "executed") +local document_id = opened.result.document_id +local document = runtime.documents:get(document_id) +local view = runtime:focused_view() +A.equal(view.document_id, document_id) +local bytes = document:read(0, #original + 8) +A.equal(bytes, original) + +-- Raw key/text and paste events use the ordinary Frontend event path. Paste is +-- deliberately binary: NUL and invalid UTF-8 must remain document bytes. +view:set_cursor(3) -- immediately after the UTF-8 BOM +local typed = runtime:handle_frontend_event({ type = "input.text", text = "X" }) +A.equal(typed.status, "inserted") +local pasted = runtime:handle_frontend_event({ type = "input.paste", bytes = "\0P\255" }) +A.equal(pasted.status, "inserted") +A.equal(document:read(0, #expected + 8), expected) + +-- Undo/redo are triggered by physical key events and resolved by Runtime's +-- default keymap, not interpreted by the Frontend. +local undo_paste = runtime:handle_frontend_event({ + type = "input.key", key = "z", action = "press", modifiers = { "ctrl" }, +}) +A.equal(undo_paste.status, "executed") +A.equal(document:read(0, #after_text + 8), after_text) +local undo_text = runtime:handle_frontend_event({ + type = "input.key", key = "z", action = "press", modifiers = { "ctrl" }, +}) +A.equal(undo_text.status, "executed") +A.equal(document:read(0, #original + 8), original) +local redo_text = runtime:handle_frontend_event({ + type = "input.key", key = "z", action = "press", modifiers = { "ctrl", "shift" }, +}) +A.equal(redo_text.status, "executed") +A.equal(document:read(0, #after_text + 8), after_text) +local redo_paste = runtime:handle_frontend_event({ + type = "input.key", key = "z", action = "press", modifiers = { "ctrl", "shift" }, +}) +A.equal(redo_paste.status, "executed") +A.equal(document:read(0, #expected + 8), expected) + +-- The default save keymap invokes the version-pinned snapshot / atomic writer. +local saved = runtime:handle_frontend_event({ + type = "input.key", key = "s", action = "press", modifiers = { "ctrl" }, +}) +A.equal(saved.status, "executed") +A.equal(host.fs:read_test_file(path), expected) +A.truthy(document:is_clean()) + +-- Close and reopen through the same request.command surface. The reopened +-- canonical document must equal the exact file bytes, including invalid UTF-8. +local closed = runtime:handle_frontend_event({ + type = "request.command", name = "document.close", arguments = {}, +}) +A.equal(closed.status, "executed") +A.equal(runtime.documents.documents[document_id], nil) +A.equal(runtime.ui.views[view.id], nil) + +local reopened = runtime:handle_frontend_event({ + type = "request.command", + name = "document.open", + arguments = { path = path }, +}) +A.equal(reopened.status, "executed") +A.equal(runtime.ui.focus, reopened.result.view_id) +local reopened_document = runtime.documents:get(reopened.result.document_id) +A.equal(reopened_document:read(0, #expected + 8), expected) +local latest_model = host.published[#host.published] +A.equal(latest_model.schema, "cdin.ui-model.v1") +A.equal(latest_model.focus, reopened.result.view_id) +local rendered_document_version +for _, view_model in pairs(latest_model.views) do + if view_model.document_id == reopened_document.id then + rendered_document_version = view_model.document_version + end +end +A.equal(rendered_document_version, reopened_document.version) + +-- Runtime owns default lifecycle shortcuts. Ctrl+N creates and focuses an +-- empty document; Ctrl+W closes only the currently focused one. +local created = runtime:handle_frontend_event({ + type = "input.key", key = "n", action = "press", modifiers = { "ctrl" }, +}) +A.equal(created.status, "executed") +A.equal(created.command, "document.new") +A.equal(runtime.ui.focus, created.result.view_id) +local new_document = runtime:focused_document() +A.equal(new_document.id, created.result.document_id) +A.truthy(new_document ~= nil) +A.equal(new_document.length, 0) +local new_id = new_document.id +local close_key = runtime:handle_frontend_event({ + type = "input.key", key = "w", action = "press", modifiers = { "ctrl" }, +}) +A.equal(close_key.status, "executed") +A.equal(runtime.documents.documents[new_id], nil) + +runtime:close_document(reopened_document.id) +A.truthy(runtime:shutdown()) + +-- Missing/unsupported Host read capabilities fail closed without creating +-- document handles or publishing phantom UI views. +local no_reader = FakeHost.new({ read_file = false }) +local no_reader_runtime = Runtime.new({ host = no_reader }) +no_reader_runtime:start() +A.error_code(function() + no_reader_runtime:handle_frontend_event({ + type = "request.command", name = "document.open", arguments = { path = path }, + }) +end, "UNSUPPORTED") +A.equal(#no_reader_runtime.documents:list(), 0) +A.equal(next(no_reader_runtime.ui.views), nil) +no_reader_runtime:shutdown() + +local missing_file_host = FakeHost.new() +local missing_file_runtime = Runtime.new({ host = missing_file_host }) +missing_file_runtime:start() +A.error_code(function() + missing_file_runtime:handle_frontend_event({ + type = "request.command", name = "document.open", arguments = { path = "missing.bin" }, + }) +end, "NOT_FOUND") +A.equal(#missing_file_runtime.documents:list(), 0) +missing_file_runtime:shutdown() + +local limited_host = FakeHost.new() +limited_host.fs:write_test_file(path, "0123456789") +local limited_runtime = Runtime.new({ host = limited_host }) +limited_runtime:start() +A.error_code(function() + limited_runtime:handle_frontend_event({ + type = "request.command", name = "document.open", + arguments = { path = path, max_bytes = 4 }, + }) +end, "LIMIT_EXCEEDED") +A.equal(#limited_runtime.documents:list(), 0) +limited_runtime:shutdown() + +-- An interrupted save must abort the temporary writer, retain the prior target +-- bytes, leave the document dirty, and release its version-pinned snapshot. +local failing_host = FakeHost.new({ fail_file_write_after_chunks = 1 }) +failing_host.fs:write_test_file(path, "previous destination") +local failing_runtime = Runtime.new({ + host = failing_host, + saver = { chunk_size = 4 }, +}) +failing_runtime:start() +local failing_document = failing_runtime:open_document("new payload that spans chunks", { path = path }) +failing_document:replace(0, 0, "edit", { expected_version = failing_document.version }) +A.truthy(not failing_document:is_clean()) +A.error_code(function() + failing_runtime:save_document(failing_document.id, path) +end, "IO_ERROR") +A.equal(failing_host.fs:read_test_file(path), "previous destination") +A.equal(failing_host.fs.aborted_writers, 1) +A.falsy(failing_document:is_clean()) +A.equal(next(failing_host.snapshots), nil) +failing_runtime:close_document(failing_document.id) +failing_runtime:shutdown() + +-- A failed publish/commit also leaves the old target untouched and aborts the +-- writer rather than leaking its temporary buffer/handle. +local commit_host = FakeHost.new({ fail_file_commit = true }) +commit_host.fs:write_test_file(path, "old") +local commit_runtime = Runtime.new({ host = commit_host, saver = { chunk_size = 2 } }) +commit_runtime:start() +local commit_document = commit_runtime:open_document("replacement", { path = path }) +commit_document:replace(0, 0, "edit", { expected_version = commit_document.version }) +A.truthy(not commit_document:is_clean()) +A.error_code(function() commit_runtime:save_document(commit_document.id, path) end, "IO_ERROR") +A.equal(commit_host.fs:read_test_file(path), "old") +A.equal(commit_host.fs.aborted_writers, 1) +A.equal(next(commit_host.snapshots), nil) +commit_runtime:close_document(commit_document.id) +commit_runtime:shutdown() + diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 567df1a..c163e3f 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -256,6 +256,7 @@ A.error_code(function() Runtime.new({ host = incomplete_host }):start() end, "HO local shutdown_ok = runtime:shutdown(); A.truthy(shutdown_ok); A.equal(runtime.state, "stopped") A.error_code(function() runtime:open_document("x") end, "SHUTDOWN") +require("integration.document_lifecycle_commands") require("integration.logging_tests") A.report("integration tests") diff --git a/tests/support/fake_host.lua b/tests/support/fake_host.lua index 2b63d32..11559ad 100644 --- a/tests/support/fake_host.lua +++ b/tests/support/fake_host.lua @@ -330,17 +330,68 @@ function M.new(options) end host.fs = {} local files = {} - function host.fs:begin_atomic_write(path) return { path = path, parts = {}, open = true } end + if options.read_file ~= false then + function host.fs:read_file(path, request) + if type(path) ~= "string" or path == "" then + errors.raise("INVALID_ARGUMENT", "file path is required") + end + if options.fail_file_read then + errors.raise("IO_ERROR", "simulated file read failure", { path = path }) + end + local bytes = files[path] + if bytes == nil then errors.raise("NOT_FOUND", "file does not exist", { path = path }) end + local maximum = request and request.max_bytes or options.max_file_read_bytes or math.huge + if type(maximum) ~= "number" or maximum <= 0 then + errors.raise("INVALID_ARGUMENT", "max_bytes must be positive") + end + if #bytes > maximum then + errors.raise("LIMIT_EXCEEDED", "file exceeds read limit", { + requested = #bytes, limit = maximum, + }) + end + return bytes + end + end + function host.fs:begin_atomic_write(path, request) + if options.fail_file_begin then errors.raise("IO_ERROR", "simulated atomic-write begin failure") end + request = request or {} + if request.overwrite == false and files[path] ~= nil then + errors.raise("IO_ERROR", "destination already exists") + end + return { path = path, parts = {}, open = true, chunks = 0 } + end function host.fs:write_chunk(writer, bytes) - if not writer.open or type(bytes) ~= "string" then errors.raise("INVALID_STATE", "invalid atomic writer") end + if not writer.open or type(bytes) ~= "string" then + errors.raise("INVALID_STATE", "invalid atomic writer") + end + if options.fail_file_write_after_chunks ~= nil + and writer.chunks >= options.fail_file_write_after_chunks then + errors.raise("IO_ERROR", "simulated interrupted atomic write") + end writer.parts[#writer.parts + 1] = bytes + writer.chunks = writer.chunks + 1 end function host.fs:commit_atomic_write(writer) if not writer.open then errors.raise("INVALID_STATE", "writer is closed") end - files[writer.path] = table.concat(writer.parts); writer.open = false; return true + if options.fail_file_commit then errors.raise("IO_ERROR", "simulated atomic-write commit failure") end + files[writer.path] = table.concat(writer.parts) + writer.open = false + return true + end + function host.fs:abort_atomic_write(writer) + host.fs.aborted_writers = (host.fs.aborted_writers or 0) + 1 + writer.open = false + writer.parts = {} + return true end - function host.fs:abort_atomic_write(writer) writer.open = false; writer.parts = {}; return true end function host.fs:read_test_file(path) return files[path] end + function host.fs:write_test_file(path, bytes) + if type(path) ~= "string" or path == "" or type(bytes) ~= "string" then + errors.raise("INVALID_ARGUMENT", "write_test_file requires path and bytes") + end + files[path] = bytes + return true + end return host end