Skip to content

Commit d160998

Browse files
cailmdaleyclaude
andcommitted
Daemon: /felt-edit handles set/unset/due for cross-host horizon writes
Extends the host-routed felt-edit endpoint beyond tags so the cross-host kanban horizon edit (`horizon`/`cold` opaque scalars + the native `due:` date) routes through the owner daemon over the SSH tunnel instead of a bespoke remote writer. Body gains `set` (object of scalar key/values → `felt edit --set key=value`, values stay YAML-typed so a JSON boolean lands as a YAML boolean), `unset` (→ `--unset key`), and `due` (absent leaves it, `null` clears via `--due ""`, a string sets it). felt owns the validation — native-key guard, scalar-only, structured-clobber refusal — and surfaces a loud non-zero exit, so the daemon does not re-implement those rails. The empty diff stays a 200 no-op. Single-writer at the document holds: the owner daemon shells the one felt CLI that both planes use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9172543 commit d160998

2 files changed

Lines changed: 107 additions & 8 deletions

File tree

lib/shuttle_web/controllers/felt_edit_controller.ex

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ defmodule ShuttleWeb.FeltEditController do
66
resolves to this daemon, so a viewer's tag edit on a remote-owned card routes
77
here over the SSH tunnel instead of editing the viewer's own loom mirror.
88
Single-writer at the document holds: the owner daemon is the lone writer of a
9-
fiber it owns, and `felt edit` is the single felt-native tag writer (the same
10-
CLI Portolan shells for local cards).
9+
fiber it owns, and `felt edit` is the single felt-native writer (the same CLI
10+
Portolan shells for local cards) for tags, opaque scalar frontmatter, and the
11+
native `due:` date.
1112
1213
`POST /api/v1/felt-edit` body: `{ "fiber_id": "...", "add": [...],
13-
"remove": [...] }`. An empty diff is a 200 no-op.
14+
"remove": [...], "set": {"key": scalar, ...}, "unset": [...], "due": "..." }`.
15+
16+
* `add` / `remove` — tag diff (`felt edit --tag/--untag`).
17+
* `set` — opaque top-level scalar frontmatter (`felt edit --set key=value`);
18+
the felt CLI reads each value as a YAML scalar so booleans/numbers keep
19+
their type. Used by the cross-host kanban horizon edit (`horizon`/`cold`).
20+
* `unset` — remove opaque top-level keys (`felt edit --unset key`).
21+
* `due` — the native date. Absent leaves it; `null` clears it
22+
(`--due ""`); a string sets it (`--due <value>`).
23+
24+
felt itself owns the validation (native-key guard, scalar-only, structured
25+
clobber refusal) and surfaces a loud non-zero exit, so the daemon does not
26+
re-implement those rails. An empty diff (no tags, no set/unset, no `due` key)
27+
is a 200 no-op.
1428
"""
1529

1630
use Phoenix.Controller, formats: [:json]
@@ -20,9 +34,12 @@ defmodule ShuttleWeb.FeltEditController do
2034
def create(conn, %{"fiber_id" => fiber_id} = params) when is_binary(fiber_id) do
2135
add = string_list(params["add"])
2236
remove = string_list(params["remove"])
37+
unset = string_list(params["unset"])
2338

24-
with {:ok, host, address} <- host_for_fiber(fiber_id),
25-
{:ok, output} <- run(host, address, add, remove) do
39+
with {:ok, set_pairs} <- set_pairs(params["set"]),
40+
{:ok, due_args} <- due_args(params),
41+
{:ok, host, address} <- host_for_fiber(fiber_id),
42+
{:ok, output} <- run(host, address, add, remove, unset, set_pairs, due_args) do
2643
conn
2744
|> put_resp_content_type("text/plain")
2845
|> send_resp(200, output)
@@ -52,13 +69,16 @@ defmodule ShuttleWeb.FeltEditController do
5269
end
5370
end
5471

55-
# An empty diff is a no-op, mirroring Portolan's local `runFeltTagEdit`.
56-
defp run(_host, _fiber_id, [], []), do: {:ok, ""}
72+
# An empty diff is a no-op, mirroring Portolan's local felt-edit path.
73+
defp run(_host, _fiber_id, [], [], [], [], []), do: {:ok, ""}
5774

58-
defp run(host, fiber_id, add, remove) do
75+
defp run(host, fiber_id, add, remove, unset, set_pairs, due_args) do
5976
args = ["-C", host, "edit", fiber_id]
6077
args = Enum.reduce(remove, args, fn tag, acc -> acc ++ ["--untag", tag] end)
6178
args = Enum.reduce(add, args, fn tag, acc -> acc ++ ["--tag", tag] end)
79+
args = Enum.reduce(unset, args, fn key, acc -> acc ++ ["--unset", key] end)
80+
args = Enum.reduce(set_pairs, args, fn pair, acc -> acc ++ ["--set", pair] end)
81+
args = args ++ due_args
6282

6383
case System.cmd("felt", args, stderr_to_stdout: true) do
6484
{output, 0} -> {:ok, output}
@@ -68,6 +88,39 @@ defmodule ShuttleWeb.FeltEditController do
6888
e in ErlangError -> {:error, Exception.message(e)}
6989
end
7090

91+
# `set` is a map of opaque scalar frontmatter. Render each entry as the
92+
# `key=value` argument `felt edit --set` expects; felt re-parses the value as
93+
# a YAML scalar (so a JSON boolean `true` lands as the YAML boolean `true`).
94+
# Non-scalar values are refused here with a 400 rather than handed to felt.
95+
defp set_pairs(nil), do: {:ok, []}
96+
97+
defp set_pairs(map) when is_map(map) do
98+
Enum.reduce_while(map, {:ok, []}, fn {key, value}, {:ok, acc} ->
99+
case scalar_string(value) do
100+
{:ok, encoded} -> {:cont, {:ok, acc ++ ["#{key}=#{encoded}"]}}
101+
:error -> {:halt, {:error, "set value for #{key} must be a scalar"}}
102+
end
103+
end)
104+
end
105+
106+
defp set_pairs(_), do: {:error, "set must be an object of key/value pairs"}
107+
108+
defp scalar_string(value) when is_binary(value), do: {:ok, value}
109+
defp scalar_string(value) when is_boolean(value), do: {:ok, to_string(value)}
110+
defp scalar_string(value) when is_number(value), do: {:ok, to_string(value)}
111+
defp scalar_string(_), do: :error
112+
113+
# `due`: absent leaves the date untouched, `null` clears it (`--due ""`), a
114+
# string sets it. felt validates the date format and rejects loudly.
115+
defp due_args(params) do
116+
case Map.fetch(params, "due") do
117+
:error -> {:ok, []}
118+
{:ok, nil} -> {:ok, ["--due", ""]}
119+
{:ok, value} when is_binary(value) -> {:ok, ["--due", value]}
120+
{:ok, _} -> {:error, "due must be a string or null"}
121+
end
122+
end
123+
71124
defp string_list(values) when is_list(values) do
72125
values
73126
|> Enum.filter(&is_binary/1)

test/shuttle_web/controllers/felt_edit_controller_test.exs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,52 @@ defmodule ShuttleWeb.FeltEditControllerTest do
4747
"-C\n#{store}\nedit\ntests/remote-tags\n--untag\nold\n--tag\nconstitution\n--tag\nnew\n"
4848
end
4949

50+
test "routes a horizon edit through felt edit --unset/--set/--due" do
51+
root =
52+
System.tmp_dir!()
53+
|> Path.join("shuttle-felt-edit-horizon-#{System.unique_integer([:positive])}")
54+
55+
store = Path.join(root, "loom")
56+
fiber_dir = Path.join([store, ".felt", "tests", "remote-tags"])
57+
File.mkdir_p!(fiber_dir)
58+
59+
File.write!(
60+
Path.join(fiber_dir, "remote-tags.md"),
61+
"---\nname: Remote tags\nstatus: active\n---\n\nbody\n"
62+
)
63+
64+
args_file = install_fake_felt!(root)
65+
old_loom_homes = System.get_env("LOOM_HOMES")
66+
System.put_env("LOOM_HOMES", store)
67+
68+
on_exit(fn ->
69+
restore_env("LOOM_HOMES", old_loom_homes)
70+
File.rm_rf(root)
71+
end)
72+
73+
conn =
74+
post(
75+
api_conn(),
76+
"/api/v1/felt-edit",
77+
Jason.encode!(%{
78+
"fiber_id" => "tests/remote-tags",
79+
"set" => %{"horizon" => "stashed", "cold" => true},
80+
"unset" => [],
81+
"due" => nil
82+
})
83+
)
84+
85+
assert conn.status == 200
86+
87+
# Boolean preserved as a YAML-typed scalar argument; `due: null` clears via
88+
# an empty --due. Set args appear in map order; cold/horizon both present.
89+
args = File.read!(args_file)
90+
assert args =~ "--set\nhorizon=stashed\n"
91+
assert args =~ "--set\ncold=true\n"
92+
assert args =~ "--due\n\n"
93+
assert String.starts_with?(args, "-C\n#{store}\nedit\ntests/remote-tags\n")
94+
end
95+
5096
test "an empty diff is a 200 no-op that never shells felt edit" do
5197
root =
5298
System.tmp_dir!()

0 commit comments

Comments
 (0)