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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ Department execution 由 supervise spawn `fkst-framework run <lua> --project-roo

Codex 调用固定为 `codex exec --dangerously-bypass-approvals-and-sandbox [-C worktree] [--context context] -`。prompt 写入 stdin;stdin EOF 是调用边界。stdout、stderr、exit_code、cmd、done time、stall window 必须写入 codex log。`spawn_codex` 返回的 handle 只能由同一 pipeline 的 `await_all` 消费,不能跨 pipeline 复用。

`fkst-framework test` 注册 test-mode-only `fkst.test.mock_command(pattern, result)` 与 `fkst.test.command_calls()`,和 `run_department` 并列。test mode 劫持 `exec_sync`、codex SDK 与 git SDK 的外部命令调用,按渲染命令行前缀或子串匹配 mock,按注册顺序一次性消费;未 mock 的外部命令 fail closed,不启动真实进程。production `run`、`supervise`、`--self-test` 与 conformance 不注册 mock state。`setup_worktree` 在 test mode 通过同一 git mock runner fail closed,但不模拟 worktree 副作用。

## 单 repo 单实例

一个 host git repo 对应一个 supervisor、一个 framework binary、一组 package+host composed graph 和一个 `FKST_RUNTIME_ROOT`。多 repo 或多业务是多次部署,不是在同一 framework 进程里跑多套主链路。
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ consumer 的完整事件日志会落在 `<RT>/logs/framework-child/` 下。真

Lua 单元测试由 `fkst-framework test` 执行。runner 只发现 package root 和 host root 下的 `departments/*/*_test.lua` 与 `tests/*_test.lua`,不全树递归,也不扫描 `raisers/` 或 `fkst/`。测试文件应 `return { test_name = function() ... end }`;runner 按文件路径和 `test_*` key 排序,失败后继续执行后续测试,最后输出通过 / 失败汇总。

`fkst.test` 只在 `test` 子命令的 Lua state 中注册,不属于 production Lua SDK surface;`run` 与 `supervise` 模式不可依赖它。当前断言只有 `eq(actual, expected[, msg])`、`is_true(value[, msg])`、`raises(fn[, msg])` 和 `is_nil(value[, msg])`。test-mode 还提供 `run_department(path, event[, opts])`,用 fresh Lua state、production SDK 和独立 raise buffer 执行一个 department entrypoint,返回 `{ exit_code = int, raises = { { queue = string, payload = table }, ... } }`;queue 解析与 production 一致。每个测试文件按所属 graph root 隔离执行,相对路径按该测试文件所属 owner package root 解析,`opts.cwd`、`opts.env`、`opts.path_prepend` 只作用于该次执行并随后恢复。这是最小单测工具,不提供 fixture、mock、hook 或测试框架 DSL;除非有意验证真实 CLI 路径,Lua 单测不应调用 `spawn_codex_sync`。
`fkst.test` 只在 `test` 子命令的 Lua state 中注册,不属于 production Lua SDK surface;`run` 与 `supervise` 模式不可依赖它。当前断言只有 `eq(actual, expected[, msg])`、`is_true(value[, msg])`、`raises(fn[, msg])` 和 `is_nil(value[, msg])`。test-mode 还提供 `run_department(path, event[, opts])`,用 fresh Lua state、production SDK 和独立 raise buffer 执行一个 department entrypoint,返回 `{ exit_code = int, raises = { { queue = string, payload = table }, ... } }`;queue 解析与 production 一致。每个测试文件按所属 graph root 隔离执行,相对路径按该测试文件所属 owner package root 解析,`opts.cwd`、`opts.env`、`opts.path_prepend` 只作用于该次执行并随后恢复。

`fkst.test.mock_command(pattern, result)` 劫持 test mode 中的 `exec_sync`、codex SDK 与 git SDK 外部命令调用;渲染命令行按前缀或子串匹配,mock 按注册顺序一次性消费。`result` 是 `{ stdout = "", stderr = "", exit_code = 0 }` 形状,`stderr` 与 `exit_code` 可省略。未 mock 的外部命令 fail closed 且不启动真实进程。`fkst.test.command_calls()` 返回已记录调用,包含渲染命令、program、args、stdin、stdout、stderr 与 exit_code。`setup_worktree` 在 test mode 也通过 git mock runner,但 mock 不合成 worktree 副作用。

production Lua SDK 包含 `once(key, fn) -> boolean`。它是 best-effort per-key de-bounce scratch marker,不是 durable state。`key` 必须是非空相对 filesystem path,`/` 表示目录;每个 segment 非空、匹配 `[A-Za-z0-9._-]+`,且不是 `.` 或 `..`;禁止 leading / trailing `/`、`//`、反斜杠、NUL 与绝对路径。framework 直接使用校验后的 key,在 `<RT>/locks/once/<key>` 上获取 exclusive flock,再检查 `<RT>/marks/<key>`。`locks/once/` 是 once 内部锁的保留子目录,不属于 `with_lock` 用户锁命名空间。marker 已存在时返回 `false` 且不调用 `fn`;marker 不存在时调用 `fn`,成功后写入 marker 并返回 `true`;`fn` 失败时错误原样传播且不写 marker,后续调用会重试。

Expand Down
1 change: 1 addition & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
- `cache_set(key, value)` 与 `cache_get(key)` 读写 `<RT>/cache/<key>`。`cache_set` 原子覆盖写入 string value;`cache_get` 命中时返回 string,缺失时返回 nil。cache 是 host-local best-effort scratch,不是 durable state;调用者需要 read-compare-write 原子性时必须外层使用 `with_lock`。
- Department 默认以可靠方式消费队列;`M.spec.ephemeral = {"queue"}` 可将本 Department 对指定 consumed queue 的订阅降级为非可靠。`M.spec.retry = false` 只表示失败不重试;`M.spec.retry = { ... }` 可覆盖 `max_attempts`、`base`、`cap` 的任意子集,缺失字段从全局默认补齐。可靠订阅启动必须有 `FKST_DURABLE_ROOT`,缺失 fail-closed。可靠 source event 必须带 `SourceRef{kind,reference}`;cron 由 raiser 名派生,file_watch 由绝对路径派生,Department `RAISED` 进入可靠 queue 时继承上游 source_ref,缺失则 publish fail-closed 且上游 delivery 不 ack。可靠 consumer 由 Fanout wake + 定时 tick 调用 redb store `lease`,spawn framework 后仅在 exit 0 且 RAISED publish 成功时 `ack`;失败、stall、spawn error 或 RAISED publish 失败调用 `retry`,到 max attempts 写 redb dead 表并 best-effort publish `dead_letter`。当前 delivery 来自 `dead_letter` 时抑制再次发送 `dead_letter`。该机制不是新 source kind,不提供 exactly-once;语义是 at-least-once-until-ack,`Fanout::send` 在可靠路径只作进程内唤醒。
- `spawn_codex` handle 只能由 `await_all` join;单 handle 等待使用 `await_all({handle})`;first-result fanout 与 sleep timer 不是固定 Lua SDK surface。
- `fkst-framework test` 额外注册 test-mode-only `fkst.test` table。除断言与 `run_department` 外,`fkst.test.mock_command(pattern, result)` 与 `fkst.test.command_calls()` 可劫持 `exec_sync`、codex SDK 与 git SDK 的外部命令调用;匹配基于渲染命令行的前缀或子串,mock 按注册顺序一次性消费,未 mock 的外部命令 fail closed 且不启动真实进程。production `run`、`supervise`、`--self-test` 与 conformance 不注册该 mock state。`setup_worktree` 在 test mode 也经同一 git mock runner,但不模拟 worktree filesystem 副作用。
- `json` 是 decode-only:`json.decode` 暴露 engine 自身 JSON wire format 的解析(event 进、`raise` 出都是 JSON),Lua 值经 `raise` 出引擎,故不提供 `json.encode`;`json` table 锁定为只含 `decode`,新增 encode 或其它 key 必须另走 evidence + conformance。
- 新增 SDK 函数必须经 evidence、深度共识与 conformance 覆盖,不能由单个 codex 实例直接扩张。

Expand Down
116 changes: 116 additions & 0 deletions crates/fkst-framework/src/external_command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use std::sync::{Arc, Mutex};

#[derive(Clone, Debug)]
pub(crate) struct MockCommandState {
inner: Arc<Mutex<MockCommandInner>>,
}

#[derive(Debug, Default)]
struct MockCommandInner {
mocks: Vec<MockCommand>,
calls: Vec<MockCommandCall>,
}

#[derive(Clone, Debug)]
struct MockCommand {
pattern: String,
result: MockCommandResult,
}

#[derive(Clone, Debug)]
pub(crate) struct MockCommandResult {
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) exit_code: i32,
}

#[derive(Clone, Debug)]
pub(crate) struct MockCommandCall {
pub(crate) rendered: String,
pub(crate) program: String,
pub(crate) args: Vec<String>,
pub(crate) stdin: String,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) exit_code: i32,
}

impl MockCommandState {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(MockCommandInner::default())),
}
}

pub(crate) fn reset(&self) -> mlua::Result<()> {
let mut inner = self.lock()?;
inner.mocks.clear();
inner.calls.clear();
Ok(())
}

pub(crate) fn push_mock(&self, pattern: String, result: MockCommandResult) -> mlua::Result<()> {
let mut inner = self.lock()?;
inner.mocks.push(MockCommand { pattern, result });
Ok(())
}

pub(crate) fn calls(&self) -> mlua::Result<Vec<MockCommandCall>> {
let inner = self.lock()?;
Ok(inner.calls.clone())
}

pub(crate) fn execute(
&self,
rendered: String,
program: String,
args: Vec<String>,
stdin: String,
) -> mlua::Result<MockCommandResult> {
let mut inner = self.lock()?;
let index = inner
.mocks
.iter()
.position(|mock| {
rendered.starts_with(&mock.pattern) || rendered.contains(&mock.pattern)
})
.ok_or_else(|| {
mlua::Error::external(format!("unmocked external command: {rendered}"))
})?;
let mock = inner.mocks.remove(index);
inner.calls.push(MockCommandCall {
rendered,
program,
args,
stdin,
stdout: mock.result.stdout.clone(),
stderr: mock.result.stderr.clone(),
exit_code: mock.result.exit_code,
});
Ok(mock.result)
}

fn lock(&self) -> mlua::Result<std::sync::MutexGuard<'_, MockCommandInner>> {
self.inner
.lock()
.map_err(|_| mlua::Error::external("mock command state lock is poisoned"))
}
}

pub(crate) fn format_command(program: &str, args: &[String]) -> String {
std::iter::once(program.to_string())
.chain(args.iter().map(|arg| shell_quote(arg)))
.collect::<Vec<_>>()
.join(" ")
}

fn shell_quote(value: &str) -> String {
if value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b'='))
{
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
}
1 change: 1 addition & 0 deletions crates/fkst-framework/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use serde_json::Value as JsonValue;
use std::path::PathBuf;

mod config_registry;
mod external_command;
mod host_conformance;
mod mlua_init;
mod path_resolver;
Expand Down
22 changes: 22 additions & 0 deletions crates/fkst-framework/src/mlua_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use serde_json::Value as JsonValue;
use std::path::Path;

use crate::config_registry::ConfigContext;
use crate::external_command::MockCommandState;
use crate::path_resolver::{package_root_path, NameResolver};
use crate::raise::RaiseBuffer;

Expand Down Expand Up @@ -35,6 +36,27 @@ pub fn register_framework_sdk(
Ok(())
}

pub(crate) fn register_framework_sdk_with_runner(
lua: &Lua,
raise_buf: RaiseBuffer,
host_root: &Path,
resolver: NameResolver,
owner_namespace: String,
runner: Option<MockCommandState>,
) -> mlua::Result<()> {
let config = ConfigContext::from_host_root(host_root).map_err(mlua::Error::external)?;
crate::sdk_log::register(lua)?;
crate::sdk_basic::register_with_runner(lua, runner.clone())?;
crate::sdk_fs::register(lua)?;
crate::sdk_json::register(lua)?;
crate::sdk_git::register_with_runner(lua, host_root, config.clone(), runner.clone())?;
crate::sdk_mark::register(lua, host_root)?;
crate::sdk_cache::register(lua, host_root)?;
crate::sdk_codex::register_with_runner(lua, host_root, config, runner)?;
crate::raise::register(lua, raise_buf, resolver, owner_namespace)?;
Ok(())
}

/// Convert serde_json::Value to mlua::Value via LuaSerdeExt.
pub fn json_to_lua(lua: &Lua, v: &JsonValue) -> mlua::Result<LuaValue> {
lua.to_value(v)
Expand Down
27 changes: 24 additions & 3 deletions crates/fkst-framework/src/sdk_basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use std::process::{Command, Stdio};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use crate::external_command::MockCommandState;

struct ExecOptions {
cmd: String,
cwd: Option<String>,
Expand All @@ -25,6 +27,10 @@ struct ExecResult {

// Lua SDK registration and self-test match the fixed CLAUDE.md surface exactly; human notification, if needed, is represented through existing git/fs/log facts rather than a new SDK function.
pub fn register(lua: &Lua) -> Result<()> {
register_with_runner(lua, None)
}

pub(crate) fn register_with_runner(lua: &Lua, runner: Option<MockCommandState>) -> Result<()> {
lua.globals().set(
"now",
lua.create_function(|_, ()| {
Expand All @@ -38,9 +44,9 @@ pub fn register(lua: &Lua) -> Result<()> {

lua.globals().set(
"exec_sync",
lua.create_function(|lua, arg: Value| {
lua.create_function(move |lua, arg: Value| {
let opts = parse_exec_options(arg)?;
let out = run_exec_sync(opts)?;
let out = run_exec_sync(opts, runner.as_ref())?;
let t = lua.create_table()?;
t.set("stdout", out.stdout)?;
t.set("stderr", out.stderr)?;
Expand Down Expand Up @@ -123,7 +129,22 @@ fn kill_process_group(child_pid: u32) {
#[cfg(not(unix))]
fn kill_process_group(_child_pid: u32) {}

fn run_exec_sync(opts: ExecOptions) -> Result<ExecResult> {
fn run_exec_sync(opts: ExecOptions, runner: Option<&MockCommandState>) -> Result<ExecResult> {
if let Some(runner) = runner {
let result = runner.execute(
opts.cmd.clone(),
"/bin/sh".to_string(),
vec!["-c".to_string(), opts.cmd],
String::new(),
)?;
return Ok(ExecResult {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
timed_out: None,
});
}

match opts.timeout {
Some(timeout) => run_exec_sync_with_timeout(&opts, timeout),
None => {
Expand Down
Loading
Loading