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: 1 addition & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ export async function transitionTypedAction(
).proposal;
}

async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
export async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(chatApiUrl(url), {
Expand Down
46 changes: 46 additions & 0 deletions apps/presentation/dashboard/src/data/goal-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {z} from "zod";
import {ChatApiError, requestJson} from "./chat";
// The local migration owner's existing built-in subset. Do not pull the Node
// provider graph into the browser; this schema validates its HTTP projection.
const provider = z.enum(["file", "sqlite"]);
export type MigrationProvider = z.infer<typeof provider>;
export const storageCarrierSchema = z.object({
goal_id: z.string().min(1), preview_id: z.string().regex(/^[a-f0-9]{32}$/),
plan_sha256: z.string().regex(/^[a-f0-9]{64}$/),
});
export type StorageCarrier = z.infer<typeof storageCarrierSchema>;
export const storageSourceSchema = z.object({
goal_id: z.string(), canonical: z.boolean(), provider: provider.nullable(),
store_identity: z.string().optional(), provider_revision: z.string().optional(), cursor: z.string().optional(),
todo_count: z.number().int().nonnegative().optional(), unsettled_lease_count: z.number().int().nonnegative().optional(),
});
export type StorageSource = z.infer<typeof storageSourceSchema>;
export const storageResultSchema = z.object({
ok: z.boolean(), goal_id: z.string(), status: z.string(),
execution_authority_granted: z.literal(false), authority_changed: z.boolean().nullable(),
preview_id: z.string().regex(/^[a-f0-9]{32}$/).optional(),
plan_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
target_provider: provider.optional(), selected_provider: provider.optional(),
reviewed_source: z.object({provider, cursor: z.string(), provider_revision: z.string(), store_identity: z.string()}).optional(),
current: storageSourceSchema.nullable().optional(),
recovery: z.object({phase: z.enum(["prepared", "completed"]), target_store_identity: z.string(), archive_sha256: z.string()}).nullable().optional(),
reason_code: z.string().optional(),
});
export type StorageResult = z.infer<typeof storageResultSchema>;

async function read(url: string, init?: RequestInit): Promise<StorageResult> {
try { return storageResultSchema.parse(await requestJson<unknown>(url, init)); }
catch (error) {
if (error instanceof ChatApiError && error.payload.http_status === 409) return storageResultSchema.parse(error.payload);
throw error;
}
}
export function fetchGoalStorage(goalId: string) {
return read(`/api/chat/goal-storage?${new URLSearchParams({goal_id: goalId})}`);
}
export function previewGoalStorage(goalId: string, target: MigrationProvider) {
return read("/api/chat/goal-storage/preview", {method: "POST", body: JSON.stringify({goal_id: goalId, provider: target})});
}
export function recoverGoalStorage(carrier: StorageCarrier, apply = false) {
return read(`/api/chat/goal-storage/${apply ? "apply" : "recover"}`, {method: "POST", body: JSON.stringify(storageCarrierSchema.parse(carrier))});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {useEffect, useRef, useState} from "react";
import {fetchGoalStorage, previewGoalStorage, recoverGoalStorage, storageCarrierSchema,
type StorageCarrier, type StorageResult, type StorageSource, type MigrationProvider} from "../../data/goal-storage";
import {useWorkspaceI18n} from "./i18n";

/** Only the immutable opaque carrier survives reload. Neither cached source
* facts nor browser confirmation replace the server's current source checks. */
export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChanged: () => void}) {
const {t} = useWorkspaceI18n();
const key = `loopx-storage-preview:${goalId}`;
const [current, setCurrent] = useState<StorageSource | null>(null);
const [carrier, setCarrier] = useState<StorageCarrier | null>(null);
const [result, setResult] = useState<StorageResult | null>(null);
const [target, setTarget] = useState<MigrationProvider>("sqlite");
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [invalidSaved, setInvalidSaved] = useState(false);
const inFlight = useRef(false);
const generation = useRef(0);
useEffect(() => {
const token = ++generation.current;
setCurrent(null); setResult(null); setCarrier(null); setConfirmed(false); setInvalidSaved(false); setError(null); setBusy(true);
let saved: StorageCarrier | null = null;
try {
const raw = localStorage.getItem(key);
saved = raw ? storageCarrierSchema.parse(JSON.parse(raw)) : null;
if (saved && saved.goal_id !== goalId) throw new Error("Goal mismatch");
setCarrier(saved);
} catch { setInvalidSaved(true); setError(t("storage.savedInvalid")); }
void (async () => {
try {
const observed = await fetchGoalStorage(goalId);
if (token !== generation.current) return;
if (observed.ok && observed.current) {
setCurrent(observed.current);
setTarget(observed.current.provider === "sqlite" ? "file" : "sqlite");
} else setError(t("storage.unavailable"));
if (saved) {
const recovered = await recoverGoalStorage(saved);
if (token !== generation.current) return;
setResult(recovered);
if (recovered.current !== undefined) setCurrent(recovered.current);
if (!recovered.ok) setError(t("storage.rejected"));
else if (!recovered.current) setError(t("storage.unavailable"));
}
} catch { if (token === generation.current) setError(t("storage.unavailable")); }
finally { if (token === generation.current) setBusy(false); }
})();
return () => { generation.current++; };
}, [goalId, key, reload, t]);

async function submit(apply: boolean) {
if (inFlight.current || busy || invalidSaved || (apply && (!carrier || !confirmed))) return;
inFlight.current = true; setBusy(true); setError(null);
const token = generation.current;
try {
const next = apply ? await recoverGoalStorage(carrier!, true) : await previewGoalStorage(goalId, target);
if (token !== generation.current) return;
setResult(next);
if (!apply && next.ok) {
const saved = storageCarrierSchema.parse(next);
// Persist before showing an enabled write control. Storage failure must
// not allow a write whose original intent cannot be recovered.
localStorage.setItem(key, JSON.stringify(saved)); setCarrier(saved); setConfirmed(false);
}
if (apply) setCurrent(next.current ?? null);
if (!next.ok) setError(t("storage.rejected"));
else if (apply) {
setConfirmed(false);
if (!next.current) setError(t("storage.unavailable"));
onChanged();
}
} catch {
if (token === generation.current) {
if (apply) { setCurrent(null); setConfirmed(false); }
setError(t(apply ? "storage.ambiguous" : "storage.unavailable"));
}
}
finally { inFlight.current = false; if (token === generation.current) setBusy(false); }
}
function discard() {
try { localStorage.removeItem(key); }
catch { setError(t("storage.savedInvalid")); return; }
setCarrier(null); setResult(null); setConfirmed(false); setInvalidSaved(false); setError(null);
}
const completed = result?.recovery?.phase === "completed";
return <section className="personal-cadence-settings" aria-label={t("storage.title")}>
<div className="personal-cadence-form">
<h3>{t("storage.title")}</h3>
<p>{t("storage.boundary")}</p>
{current ? <div className="personal-cadence-readback"><small>{t("storage.current")}</small>
<strong>{current.provider ?? t("ownership.unpromoted")}</strong>
{current.canonical ? <span>{t("storage.counts", {todos: current.todo_count ?? 0, leases: current.unsettled_lease_count ?? 0})}</span> : <p>{t("storage.promoteFirst")}</p>}
</div> : null}
{current?.canonical || carrier ? <>
{carrier ? <p>{t("storage.reviewed", {source: result?.reviewed_source?.provider ?? "?", target: result?.target_provider ?? "?", cursor: result?.reviewed_source?.cursor ?? "?"})}</p>
: <label>{t("storage.target")}<select aria-label={t("storage.target")} value={target} disabled={busy} onChange={e => setTarget(e.target.value as MigrationProvider)}>
<option value="sqlite">SQLite</option><option value="file">File</option></select></label>}
{carrier && !completed ? <label><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{t("storage.confirm")}</label> : null}
<div className="personal-cadence-actions">
{carrier ? <><button className="is-primary" disabled={busy || !confirmed || completed || !result?.ok} onClick={() => void submit(true)} type="button">{t("storage.apply")}</button>
<button disabled={busy} onClick={discard} type="button">{t("storage.fresh")}</button></>
: <button className="is-primary" disabled={busy || invalidSaved || !current?.provider || target === current.provider} onClick={() => void submit(false)} type="button">{t("storage.preview")}</button>}
</div>
</> : null}
<div className="personal-cadence-actions">
{invalidSaved ? <button disabled={busy} onClick={discard} type="button">{t("storage.fresh")}</button> : null}
<button disabled={busy} onClick={() => setReload(n => n + 1)} type="button">{t(carrier ? "storage.recover" : "storage.refresh")}</button>
</div>
{completed ? <p role="status">{t("storage.completed")}</p> : null}
{result?.recovery?.phase === "prepared" ? <p role="status">{t("storage.prepared")}</p> : null}
{error ? <p className="personal-machine-error" role="alert">{error}</p> : null}
{result?.reason_code ? <p><code>{result.reason_code}</code></p> : null}
{current?.canonical || carrier ? <details><summary>{t("storage.details")}</summary>
{current?.canonical ? <p>{current.store_identity} · {current.provider_revision} · {current.cursor}</p> : null}
{carrier ? <p>{carrier.preview_id} · {carrier.plan_sha256}</p> : null}
</details> : null}
{busy ? <p aria-live="polite">{t("common.loading")}</p> : null}
</div>
</section>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,33 @@ export const workspaceLocaleStorageKey = "loopx-pw-locale";
export type WorkspaceLocale = "en" | "zh-CN";

const en = {
"storage.title": "Goal data storage",
"storage.boundary": "Confirmation changes only this existing Goal. New-Goal defaults and task ownership are separate. Preserve journal, metadata and receipts; no execution authority is granted.",
"storage.current": "Current source (fresh readback)",
"storage.counts": "{todos} tasks · {leases} unsettled leases",
"storage.target": "Target storage",
"storage.promoteFirst": "Previous Markdown state: use the backed-up CLI promotion first. This page switches existing canonical File/SQLite stores.",
"storage.reviewed": "Reviewed source: {source}, cursor {cursor} → {target}. A changed source rejects apply.",
"storage.confirm": "I stopped writers and settled leases. I confirm this reviewed storage change.",
"storage.apply": "Back up and switch storage",
"storage.fresh": "Discard original preview",
"storage.preview": "Preview storage change",
"storage.refresh": "Read current storage",
"storage.recover": "Recover original preview and read current storage",
"storage.completed": "The original migration completed and its backup was verified. Current storage is read separately above; a later migration may have changed it.",
"storage.prepared": "The original operation is prepared; its outcome is unconfirmed. Check current storage and retry the same preview.",
"storage.rejected": "Operation rejected. Stop capture and writers, settle leases, then read current storage. If the source changed, discard and review a fresh preview.",
"storage.ambiguous": "Reply lost or interrupted. Keep this original preview; recover or retry it to resolve the outcome.",
"storage.unavailable": "Storage unavailable. Preserve the original preview and retry readback.",
"storage.savedInvalid": "Saved preview unreadable. Explicitly discard it before creating a different intent.",
"storage.details": "Source and original operation details",
"ownership.title": "Task ownership",
"ownership.current": "Current policy",
"ownership.legacy": "Previous policy",
"ownership.soft_claim": "Collaborative claims",
"ownership.hard_lease": "Exclusive execution leases",
"ownership.unpromoted": "Not on canonical storage",
"ownership.promoteFirst": "Promote this Goal through the backed-up CLI migration first. This page does not migrate storage.",
"ownership.promoteFirst": "Promote this Goal through the backed-up CLI migration first. This policy form does not migrate storage.",
"ownership.target": "New policy",
"ownership.softHelp": "Coordinate assignment without requiring an exclusive execution lease. Active leases must be settled first.",
"ownership.hardHelp": "Ownership changes and completion require the task’s original execution lease.",
Expand Down Expand Up @@ -1335,13 +1355,33 @@ const en = {
export type WorkspaceMessageKey = keyof typeof en;

const zhCN: Record<WorkspaceMessageKey, string> = {
"storage.title": "Goal 数据存储",
"storage.boundary": "明确确认后仅切换此既有 Goal。新 Goal 默认值与任务所有权是各自的设置。保留日志、metadata 和回执,不授予执行权限。",
"storage.current": "当前来源(实时读回)",
"storage.counts": "{todos} 项任务 · {leases} 项未结算 lease",
"storage.target": "目标存储",
"storage.promoteFirst": "旧 Markdown 状态:请先通过有备份的 CLI 晋升。这里切换已存在的 canonical File/SQLite 存储。",
"storage.reviewed": "已审核来源:{source},游标 {cursor} → {target}。来源变化时拒绝应用。",
"storage.confirm": "我已停止写入方并结算 lease,确认此预览的存储变更。",
"storage.apply": "备份并切换存储",
"storage.fresh": "放弃原预览",
"storage.preview": "预览存储变更",
"storage.refresh": "读回当前存储",
"storage.recover": "恢复原预览并读回当前存储",
"storage.completed": "原迁移已完成且备份已验证。上方当前存储独立读回;后续迁移可能已改变它。",
"storage.prepared": "原操作已准备,结果尚未确认。核对当前存储后重试同一份预览。",
"storage.rejected": "操作被拒绝。请停止 capture 和写入方、结算 lease 并读回当前存储。来源已变化时,放弃并重新审核预览。",
"storage.ambiguous": "响应丢失或中断。请保留原预览,通过恢复或重试它确认结果。",
"storage.unavailable": "存储暂不可用。请保留原预览并重试读回。",
"storage.savedInvalid": "已保存预览无法读取。请明确放弃它后再创建不同操作。",
"storage.details": "来源与原操作详情",
"ownership.title": "任务所有权",
"ownership.current": "当前策略",
"ownership.legacy": "旧策略",
"ownership.soft_claim": "协作认领",
"ownership.hard_lease": "独占执行租约",
"ownership.unpromoted": "尚未使用统一状态存储",
"ownership.promoteFirst": "请先通过带备份的 CLI 迁移将 Goal 晋升到统一存储。此页面不迁移存储。",
"ownership.promoteFirst": "请先通过带备份的 CLI 迁移将 Goal 晋升到统一存储。此策略表单不迁移存储。",
"ownership.target": "新策略",
"ownership.softHelp": "协调任务归属,不要求独占执行租约。仍在运行的租约必须先结算。",
"ownership.hardHelp": "变更所有权和完成任务需要持有该任务原有的执行租约。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useWorkspaceI18n } from "./i18n";
import { LarkSettingsPage } from "./lark-settings-page";
import { GoalCapabilitySettings } from "./goal-capability-settings";
import { GoalOwnershipSettings } from "./goal-ownership-settings";
import { GoalStorageSettings } from "./goal-storage-settings";
import { AutomationCadenceSettings } from "./automation-cadence-settings";
import { MachineConfigurationSettings } from "./machine-configuration-settings";
import { ConfigurationBackupSettings } from "./configuration-backup-settings";
Expand Down Expand Up @@ -221,7 +222,10 @@ export function WorkspaceSettingsPage({
onChanged={onChanged}
/>
) : null}
{tab === "ownership" && selectedGoal ? <GoalOwnershipSettings key={selectedGoal.goalId} goalId={selectedGoal.goalId} onChanged={onChanged} /> : null}
{tab === "ownership" && selectedGoal ? <div key={selectedGoal.goalId}>
<GoalOwnershipSettings goalId={selectedGoal.goalId} onChanged={onChanged} />
<GoalStorageSettings goalId={selectedGoal.goalId} onChanged={onChanged} />
</div> : null}
{tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? <ConfigurationBackupSettings key={`${capabilityScope}:${capabilityGoalId}`} goalId={capabilityScope === "goal" ? capabilityGoalId : null} /> : null}
{tab === "cadence" && selectedGoal ? <AutomationCadenceSettings key={selectedGoal.goalId} goal={selectedGoal} /> : null}

Expand Down
9 changes: 9 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,15 @@ L3 checkpoint: standalone acquisition/takeover, atomic claim admission and maint
- **Exit:** use shared-authority Section 7.2's separate decisions for a bounded change, reversible opt-in cohort and released default. Each requires affected real CLI/backend and independent baseline/negative/recovery evidence at its own scope. Formal D2 retains applicable volume and at least ten-day evidence; a cohort need not wait for that certificate. D3 retains explicit cutover authority. This plan runs no soak or provider promotion.
- **Rollback:** reviewed fenced export/import and schema-aware downgrade; replacing a binary cannot restore old write authority.

Existing canonical File/SQLite cutover now has a packaged settings journey using
the existing typed migration/archive owner: immutable preview, explicit apply,
original-plan recovery and independent current-source readback. Active capture
(including an empty queue) and unsettled leases refuse cutover. File→SQLite→new
writes→File must use the current head; historical completion never selects the
provider. This is a bounded App companion, not full old-source/Host upgrade or
D2/release-default qualification. Continue those original acceptance frontiers
and retire each last caller separately.

The [Goal instance/recovery RFC](goal-instance-identity-and-orphan-recovery-v0.md)
adds a bounded R5 dependency for R2/R3 retirement and late-result safety. The
codec and source-session lifetime transaction exist; attached Chat, handoff and
Expand Down
Loading
Loading