diff --git a/.gitignore b/.gitignore index 3f96ce41..fe1edfae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target /result /.direnv +/.yoi/dev .worktree *.local* .env diff --git a/.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep b/.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXSJXMR5/item.md b/.yoi/tickets/00001KXSJXMR5/item.md new file mode 100644 index 00000000..1ee12f3d --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/item.md @@ -0,0 +1,31 @@ +--- +title: 'Enable Ticket tools in default profile' +state: 'closed' +created_at: '2026-07-18T03:04:36Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T03:05:10Z' +--- + +## 背景 + +通常の `builtin:default` Worker では Ticket feature が無効で、`TicketList` / `TicketShow` などの typed Ticket tools が露出しない。実装作業では Ticket の前提・受け入れ条件・thread を読めないと正しく作業できないため、default profile でも Ticket tools を有効化する。 + +Orchestrator 固有の relation / orchestration plan tools は引き続き `ticket_orchestration` で分離し、default profile では有効化しない。 + +## 要件 + +- `resources/profiles/default.dcdl` の Ticket feature を有効化する。 +- builtin default profile artifact の Ticket feature も同じ既定にする。 +- setup wizard が生成する `user:default` profile でも Ticket feature を有効化する。 +- `ticket_orchestration` は default では無効のままにする。 +- 裸の `WorkerManifestConfig::builtin_defaults()` の feature default は profile 既定とは別なので変更しない。 + +## 受け入れ条件 + +- `builtin:default` profile resolution で `feature.ticket.enabled == true` になる。 +- `builtin:default` profile resolution で `feature.ticket.access == lifecycle` になる。 +- `builtin:default` profile resolution で `feature.ticket_orchestration.enabled == false` のままになる。 +- setup model が生成する default profile に `[feature.ticket] enabled = true` が含まれる。 +- 関連する profile/setup focused tests が通る。 diff --git a/.yoi/tickets/00001KXSJXMR5/resolution.md b/.yoi/tickets/00001KXSJXMR5/resolution.md new file mode 100644 index 00000000..3d425773 --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/resolution.md @@ -0,0 +1 @@ +Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations. diff --git a/.yoi/tickets/00001KXSJXMR5/thread.md b/.yoi/tickets/00001KXSJXMR5/thread.md new file mode 100644 index 00000000..eef81e2c --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/thread.md @@ -0,0 +1,85 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`builtin:default` profile と setup wizard 生成の `user:default` profile で Ticket feature を有効化した。 + +変更点: +- `resources/profiles/default.dcdl` の `feature.ticket.enabled` を `true` に変更。 +- builtin default profile artifact の `feature.ticket.enabled` を `true` に変更。 +- setup wizard が生成する default profile の `[feature.ticket] enabled` を `true` に変更。 +- builtin default profile resolution / setup profile generation のテスト期待値を追加。 +- `ticket_orchestration` は default では無効のまま維持。 +- `WorkerManifestConfig::builtin_defaults()` の裸の feature default は変更していない。 + +検証: +- `cargo test -p manifest builtin_default_resolves_without_external_evaluator --quiet` +- `cargo test -p manifest builtin_role_profiles_preserve_role_tool_policy --quiet` +- `cargo test -p tui write_default_profile_config_creates_registry_and_profile --quiet` +- `cargo test -p worker installs_ticket_tools_when_default_root_is_usable --quiet` +- `cargo fmt --check` +- `git diff --check` + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations. + + +--- diff --git a/.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep b/.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXSKP1AB/item.md b/.yoi/tickets/00001KXSKP1AB/item.md new file mode 100644 index 00000000..600a1c18 --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/item.md @@ -0,0 +1,33 @@ +--- +title: 'Add development process switch script' +state: 'closed' +created_at: '2026-07-18T03:17:55Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T03:18:16Z' +--- + +## 背景 + +開発中の backend / runtime / workspace frontend は、手動で `cargo run` や `deno` task を main worktree から起動している。作業ブランチを切った worktree に付け替える時、既存 process を安全に止めて同じ port で起動し直す手順が必要になる。 + +この worker 自体が同じ開発 process 群に依存している可能性があるため、実装時には stop/restart/start の実行は行わず、静的検証に留める。 + +## 要件 + +- `scripts/dev-workspace.sh` を追加する。 +- 引数で `start` / `stop` / `restart` を選べる。 +- `start` は runtime / backend / frontend をこの checkout から起動する。 +- `stop` は runtime / backend / frontend を停止する。 +- `restart` は frontend を触らず、runtime / backend だけ停止・起動する。 +- frontend は `0.0.0.0` bind で起動する。 +- pid/log は repository-local な runtime directory に保存し、生成物は git 管理対象外にする。 +- port listener の付け替えに対応する。 + +## 受け入れ条件 + +- `bash -n scripts/dev-workspace.sh` が通る。 +- `scripts/dev-workspace.sh --help` が安全に使い方を表示する。 +- start/stop/restart は実装するが、この作業中には実行しない。 +- runtime/backend/frontend の command と default bind/port が script 内で確認できる。 diff --git a/.yoi/tickets/00001KXSKP1AB/resolution.md b/.yoi/tickets/00001KXSKP1AB/resolution.md new file mode 100644 index 00000000..1e1c4b55 --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/resolution.md @@ -0,0 +1 @@ +Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`. diff --git a/.yoi/tickets/00001KXSKP1AB/thread.md b/.yoi/tickets/00001KXSKP1AB/thread.md new file mode 100644 index 00000000..4786d5ba --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/thread.md @@ -0,0 +1,98 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`script/dev-workspace.sh` ではなく repository root の `scripts/dev-workspace.sh` として開発 process 管理 script を追加した。 + +実装内容: +- `start` / `stop` / `restart` / `status` action を追加。 +- `start` は runtime, backend, frontend をこの checkout から起動する。 +- `stop` は runtime, backend, frontend を止める。 +- `restart` は frontend を触らず、runtime/backend だけ止めて起動し直す。 +- frontend は既定で `0.0.0.0:5173` bind。 +- backend は既定で `127.0.0.1:8787`、runtime は `127.0.0.1:38800`。 +- pid/log は `.yoi/dev/{pids,logs}` に保存し、`.gitignore` に `/.yoi/dev` を追加。 +- 既存 listener がいる場合、port listener PID を止めてからこの checkout の process を起動する。 +- managed process は pidfile の pid/process group を使って止める。pidfile 管理外の既存 listener は、caller の process group を巻き込まないよう PID 単位で止める。 + +確認: +- `bash -n scripts/dev-workspace.sh` +- `scripts/dev-workspace.sh --help` +- `scripts/dev-workspace.sh status` +- `git diff --check` + +実行しなかったこと: +- `scripts/dev-workspace.sh start` +- `scripts/dev-workspace.sh stop` +- `scripts/dev-workspace.sh restart` + +理由: +- 現在の worker 自体が既存 backend/runtime/frontend process 群に依存している可能性があり、停止・再起動するとこのセッションが落ちるため。 + +現状確認: +- frontend listener pid は `/home/hare/Projects/yoi/web/workspace` 由来。 +- backend/runtime listener pid は `/home/hare/Projects/yoi` 由来。 + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`. + + +--- diff --git a/.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep b/.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXT5FEFR/item.md b/.yoi/tickets/00001KXT5FEFR/item.md new file mode 100644 index 00000000..1350c5b0 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/item.md @@ -0,0 +1,28 @@ +--- +title: 'Limit workdir dirty source check to HEAD selector' +state: 'closed' +created_at: '2026-07-18T08:28:54Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T08:29:31Z' +--- + +## 背景 + +Runtime workdir materializer は `selector` を `git rev-parse ^{commit}` で commit に解決し、`git worktree add --detach` で detached worktree を作る。明示 branch / tag / commit selector の場合、source worktree の未コミット変更は生成される detached worktree には入らないため、dirty source を理由に拒否する必要はない。 + +一方で `HEAD` selector は現在の source worktree の状態を暗黙に参照する導線なので、既存の dirty source safety check を維持する。 + +## 要件 + +- workdir materialization の dirty source check は実質 selector が `HEAD` の時だけ行う。 +- 明示 branch selector では source repository が dirty でも commit 解決と detached worktree 作成を許す。 +- `HEAD` selector の dirty source rejection は維持する。 + +## 受け入れ条件 + +- `selector = HEAD` で source repository が dirty の場合、`working_directory_dirty_source_rejected` になる。 +- `selector = ` で source repository が dirty の場合、workdir materialization が成功する。 +- 明示 branch selector で作った workdir は dirty source の未コミットファイルを含まない。 +- focused worker-runtime tests が通る。 diff --git a/.yoi/tickets/00001KXT5FEFR/resolution.md b/.yoi/tickets/00001KXT5FEFR/resolution.md new file mode 100644 index 00000000..6c543f73 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/resolution.md @@ -0,0 +1 @@ +Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes. diff --git a/.yoi/tickets/00001KXT5FEFR/thread.md b/.yoi/tickets/00001KXT5FEFR/thread.md new file mode 100644 index 00000000..1ae0bd35 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/thread.md @@ -0,0 +1,82 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +workdir materialization の dirty source check を `selector == "HEAD"` の場合だけ実行するようにした。 + +変更点: +- `LocalGitWorktreeMaterializer::materialize` で selector を先に確定。 +- selector が `HEAD` の時だけ `git status --porcelain` による dirty source rejection を行う。 +- 明示 branch selector では source worktree が dirty でも `git rev-parse ^{commit}` で commit 解決し、detached worktree を作る。 +- branch selector + dirty source の回帰テストを追加し、未コミットファイルが materialized worktree に入らないことを確認。 + +検証: +- `cargo test -p worker-runtime --features ws-server,fs-store branch_selector_allows_dirty_source_materialization --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store dirty_source_is_rejected_by_materialization --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store local_git_repo_materializes_detached_worktree_under_runtime_root --quiet` +- `cargo fmt --check` +- `git diff --check` + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes. + + +--- diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 460adb38..d4c0f3b6 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -981,7 +981,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value { "memory": { "enabled": true }, "web": { "enabled": true }, "workers": { "enabled": true }, - "ticket": { "enabled": false, "access": "lifecycle" }, + "ticket": { "enabled": true, "access": "lifecycle" }, "ticket_orchestration": { "enabled": false } }, "memory": { @@ -1761,6 +1761,12 @@ worker_context_max_tokens = 68000 assert!(resolved.manifest.scope.allow.is_empty()); assert!(resolved.manifest.delegation_scope.allow.is_empty()); assert!(resolved.manifest.session.record_event_trace); + assert!(resolved.manifest.feature.ticket.enabled); + assert_eq!( + resolved.manifest.feature.ticket.access, + crate::TicketFeatureAccessConfig::Lifecycle + ); + assert!(!resolved.manifest.feature.ticket_orchestration.enabled); assert_eq!( resolved.profile.as_ref().unwrap().name.as_deref(), Some("default") diff --git a/crates/tui/src/setup_model.rs b/crates/tui/src/setup_model.rs index 43cb2e49..f7d2670e 100644 --- a/crates/tui/src/setup_model.rs +++ b/crates/tui/src/setup_model.rs @@ -237,7 +237,7 @@ enabled = true enabled = false [feature.ticket] -enabled = false +enabled = true access = "lifecycle" [feature.ticket_orchestration] @@ -298,6 +298,8 @@ mod tests { assert!(profile.contains("slug = \"default\"")); assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\"")); assert!(profile.contains("scope = \"workspace_write\"")); + assert!(profile.contains("[feature.ticket]\nenabled = true\naccess = \"lifecycle\"")); + assert!(profile.contains("[feature.ticket_orchestration]\nenabled = false")); } #[test] diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 9884f715..151b57e9 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -320,20 +320,21 @@ impl LocalGitWorktreeMaterializer { ) })?; - let status = git_stdout(&source_root, ["status", "--porcelain"])?; - if !status.trim().is_empty() { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_dirty_source_rejected", - "working directory materialization rejects dirty source repository state", - )); - } - let selector = request .repository .selector .as_deref() .unwrap_or("HEAD") .to_string(); + if selector == "HEAD" { + let status = git_stdout(&source_root, ["status", "--porcelain"])?; + if !status.trim().is_empty() { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_dirty_source_rejected", + "working directory materialization rejects dirty source repository state", + )); + } + } let commit_spec = format!("{selector}^{{commit}}"); let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])? .trim() @@ -851,6 +852,30 @@ mod tests { assert!(error.message.contains("dirty source")); } + #[test] + fn branch_selector_allows_dirty_source_materialization() { + let repo = create_clean_repo(); + git(repo.path(), &["branch", "pinned"]); + fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap(); + let runtime_root = tempfile::tempdir().unwrap(); + let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); + let mut request = request(repo.path()); + request.repository.selector = Some(RepositorySelector::from("pinned")); + + let binding = materializer.materialize(&worker_ref(1), &request).unwrap(); + + assert_eq!( + binding + .working_directory + .evidence + .requested_selector + .as_deref(), + Some("pinned") + ); + assert!(binding.root.join("README.md").exists()); + assert!(!binding.root.join("dirty.txt").exists()); + } + #[test] fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() { let runtime_root = tempfile::tempdir().unwrap(); diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index df896714..81d06f66 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -26,7 +26,7 @@ feature = { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = false; access = "lifecycle"; }; + ticket = { enabled = true; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; diff --git a/scripts/dev-workspace.sh b/scripts/dev-workspace.sh new file mode 100755 index 00000000..29a9e654 --- /dev/null +++ b/scripts/dev-workspace.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_DIR="${YOI_DEV_RUNTIME_DIR:-$ROOT_DIR/.yoi/dev}" +PID_DIR="$RUNTIME_DIR/pids" +LOG_DIR="$RUNTIME_DIR/logs" + +BACKEND_LISTEN="${YOI_DEV_BACKEND_LISTEN:-127.0.0.1:8787}" +RUNTIME_BIND="${YOI_DEV_RUNTIME_BIND:-127.0.0.1:38800}" +FRONTEND_HOST="${YOI_DEV_FRONTEND_HOST:-0.0.0.0}" +FRONTEND_PORT="${YOI_DEV_FRONTEND_PORT:-5173}" + +RUNTIME_ENABLED="${YOI_DEV_RUNTIME_ENABLED:-1}" + +usage() { + cat < + +Manage the local Yoi development stack for this checkout: + runtime cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --bind $RUNTIME_BIND + backend cargo run -p yoi-workspace-server -- serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN + frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace) + +Actions: + start stop existing listeners on the configured ports, then start runtime, backend, and frontend from this checkout + stop stop runtime, backend, and frontend + restart stop/start runtime and backend only; frontend is left untouched + status print pidfile and port-listener status without mutating processes + +Environment overrides: + YOI_DEV_BACKEND_LISTEN=127.0.0.1:8787 + YOI_DEV_RUNTIME_BIND=127.0.0.1:38800 + YOI_DEV_RUNTIME_ENABLED=1 set to 0 to skip the standalone runtime process + YOI_DEV_FRONTEND_HOST=0.0.0.0 + YOI_DEV_FRONTEND_PORT=5173 + YOI_DEV_RUNTIME_DIR=$ROOT_DIR/.yoi/dev +EOF +} + +log() { + printf '[dev-workspace] %s\n' "$*" >&2 +} + +ensure_dirs() { + mkdir -p "$PID_DIR" "$LOG_DIR" +} + +pid_file() { + printf '%s/%s.pid' "$PID_DIR" "$1" +} + +log_file() { + printf '%s/%s.log' "$LOG_DIR" "$1" +} + +is_running() { + local pid="$1" + [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null +} + +service_pid() { + local file + file="$(pid_file "$1")" + [[ -f "$file" ]] || return 1 + local pid + pid="$(cat "$file")" + is_running "$pid" || return 1 + printf '%s' "$pid" +} + +split_addr_port() { + local value="$1" + local port="${value##*:}" + local host="${value%:*}" + if [[ "$host" == "$value" || -z "$port" ]]; then + printf 'invalid address:port value: %s\n' "$value" >&2 + return 1 + fi + printf '%s\t%s\n' "$host" "$port" +} + +port_for_addr() { + split_addr_port "$1" | awk '{ print $2 }' +} + +listener_pids_for_port() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | sort -u + return 0 + fi + if command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :$port" 2>/dev/null \ + | sed -nE 's/.*pid=([0-9]+).*/\1/p' \ + | sort -u + return 0 + fi +} + +stop_pid() { + local pid="$1" + local label="$2" + is_running "$pid" || return 0 + + local pgid + pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$pgid" == "$pid" ]]; then + log "stopping $label process group -$pid" + kill -TERM "-$pid" 2>/dev/null || true + else + log "stopping $label pid $pid" + kill -TERM "$pid" 2>/dev/null || true + fi + + for _ in {1..50}; do + is_running "$pid" || return 0 + sleep 0.1 + done + + log "forcing $label pid $pid" + if [[ "$pgid" == "$pid" ]]; then + kill -KILL "-$pid" 2>/dev/null || true + else + kill -KILL "$pid" 2>/dev/null || true + fi +} + +stop_managed_service() { + local name="$1" + local file + file="$(pid_file "$name")" + if [[ ! -f "$file" ]]; then + return 0 + fi + + local pid + pid="$(cat "$file")" + if is_running "$pid"; then + stop_pid "$pid" "$name" + fi + rm -f "$file" +} + +stop_port_listeners() { + local label="$1" + local port="$2" + local pids=() + mapfile -t pids < <(listener_pids_for_port "$port" || true) + if [[ "${#pids[@]}" -eq 0 ]]; then + return 0 + fi + + for pid in "${pids[@]}"; do + [[ -n "$pid" ]] || continue + log "stopping existing $label listener on port $port pid $pid" + # Unmanaged dev processes may share a process group with the caller's terminal + # or pod; stop only the listener PID here. Managed processes started by this + # script are stopped by process group via pidfiles above. + kill -TERM "$pid" 2>/dev/null || true + done + + for _ in {1..50}; do + mapfile -t pids < <(listener_pids_for_port "$port" || true) + [[ "${#pids[@]}" -eq 0 ]] && return 0 + sleep 0.1 + done + + for pid in "${pids[@]}"; do + [[ -n "$pid" ]] || continue + log "forcing existing $label listener on port $port pid $pid" + kill -KILL "$pid" 2>/dev/null || true + done +} + +start_service() { + local name="$1" + local cwd="$2" + local port="$3" + shift 3 + + stop_managed_service "$name" + stop_port_listeners "$name" "$port" + + local logfile pidfile + logfile="$(log_file "$name")" + pidfile="$(pid_file "$name")" + log "starting $name; log: $logfile" + ( + cd "$cwd" + exec setsid "$@" + ) >"$logfile" 2>&1 & + local pid="$!" + printf '%s\n' "$pid" >"$pidfile" + log "$name pid $pid" +} + +start_runtime() { + if [[ "$RUNTIME_ENABLED" == "0" ]]; then + log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0" + return 0 + fi + local port + port="$(port_for_addr "$RUNTIME_BIND")" + start_service runtime "$ROOT_DIR" "$port" \ + cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --bind "$RUNTIME_BIND" +} + +start_backend() { + local port + port="$(port_for_addr "$BACKEND_LISTEN")" + start_service backend "$ROOT_DIR" "$port" \ + cargo run -p yoi-workspace-server -- serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN" +} + +start_frontend() { + local frontend_dir="$ROOT_DIR/web/workspace" + start_service frontend "$frontend_dir" "$FRONTEND_PORT" \ + deno run -A npm:vite@7.2.7 dev --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" +} + +stop_runtime_backend() { + stop_managed_service backend + stop_managed_service runtime + stop_port_listeners backend "$(port_for_addr "$BACKEND_LISTEN")" + if [[ "$RUNTIME_ENABLED" != "0" ]]; then + stop_port_listeners runtime "$(port_for_addr "$RUNTIME_BIND")" + fi +} + +start_runtime_backend() { + start_runtime + start_backend +} + +start_all() { + ensure_dirs + start_runtime_backend + start_frontend +} + +stop_all() { + ensure_dirs + stop_managed_service frontend + stop_runtime_backend + stop_port_listeners frontend "$FRONTEND_PORT" +} + +restart_runtime_backend() { + ensure_dirs + log "restarting runtime/backend only; frontend is left untouched" + stop_runtime_backend + start_runtime_backend +} + +status_service() { + local name="$1" + local port="$2" + local managed="-" + local listeners="-" + if service_pid "$name" >/dev/null; then + managed="$(service_pid "$name")" + fi + mapfile -t pids < <(listener_pids_for_port "$port" || true) + if [[ "${#pids[@]}" -gt 0 ]]; then + listeners="${pids[*]}" + fi + printf '%-8s managed_pid=%-8s port=%-6s listener_pids=%s\n' "$name" "$managed" "$port" "$listeners" +} + +status_all() { + ensure_dirs + status_service runtime "$(port_for_addr "$RUNTIME_BIND")" + status_service backend "$(port_for_addr "$BACKEND_LISTEN")" + status_service frontend "$FRONTEND_PORT" +} + +main() { + local action="${1:-}" + case "$action" in + start) + start_all + ;; + stop) + stop_all + ;; + restart) + restart_runtime_backend + ;; + status) + status_all + ;; + -h|--help|help) + usage + ;; + "") + usage + exit 2 + ;; + *) + printf 'unknown action: %s\n\n' "$action" >&2 + usage >&2 + exit 2 + ;; + esac +} + +main "$@"