Skip to content

Commit f03a9ac

Browse files
authored
Merge pull request #6004 from loopx-project/codex/cold-source-import-r128
feat(coordination): review cold Goal imports through CLI and App
2 parents 20dd1a3 + 9a94b2e commit f03a9ac

27 files changed

Lines changed: 1827 additions & 35 deletions

‎apps/presentation/dashboard/src/data/goal-storage.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import {ChatApiError, requestJson} from "./chat";
44
// provider graph into the browser; this schema validates its HTTP projection.
55
const provider = z.enum(["file", "sqlite"]);
66
export type MigrationProvider = z.infer<typeof provider>;
7-
export const storageCarrierSchema = z.object({
7+
const migrationCarrierSchema = z.object({
88
goal_id: z.string().min(1), preview_id: z.string().regex(/^[a-f0-9]{32}$/),
99
plan_sha256: z.string().regex(/^[a-f0-9]{64}$/),
1010
});
11+
// Reuse the cold-import owner's operation identity; old migration carriers
12+
// remain readable without a new protocol discriminator or guessed source.
13+
export const storageCarrierSchema = z.union([migrationCarrierSchema, z.object({
14+
goal_id: z.string().min(1), operation_id: z.string().regex(/^[a-f0-9]{32}$/),
15+
plan_sha256: z.string().regex(/^[a-f0-9]{64}$/),
16+
})]);
1117
export type StorageCarrier = z.infer<typeof storageCarrierSchema>;
1218
export const storageSourceSchema = z.object({
1319
goal_id: z.string(), canonical: z.boolean(), provider: provider.nullable(),
@@ -31,6 +37,14 @@ export const storageResultSchema = z.object({
3137
}).optional(),
3238
recovery: z.object({phase: z.enum(["prepared", "completed"]), target_store_identity: z.string(), archive_sha256: z.string()}).nullable().optional(),
3339
reason_code: z.string().optional(),
40+
operation_id: z.string().regex(/^[a-f0-9]{32}$/).optional(),
41+
target_handoff_mode: z.enum(["soft_claim", "hard_lease"]).optional(),
42+
source_inventory: z.object({todo_count: z.number().int().nonnegative(),
43+
archived_todo_count: z.number().int().nonnegative(), lease_count: z.number().int().nonnegative(),
44+
source_handoff_mode: z.string()}).optional(),
45+
legacy_writer_fenced: z.boolean().nullable().optional(),
46+
coordination_source_backup_verified: z.boolean().optional(),
47+
complete_goal_backup_verified: z.literal(false).optional(),
3448
});
3549
export type StorageResult = z.infer<typeof storageResultSchema>;
3650

@@ -44,9 +58,14 @@ async function read(url: string, init?: RequestInit): Promise<StorageResult> {
4458
export function fetchGoalStorage(goalId: string) {
4559
return read(`/api/chat/goal-storage?${new URLSearchParams({goal_id: goalId})}`);
4660
}
47-
export function previewGoalStorage(goalId: string, target: MigrationProvider) {
61+
export function previewGoalStorage(goalId: string, target: MigrationProvider, mode?: "soft_claim" | "hard_lease") {
62+
if (mode) return read("/api/chat/goal-storage/import/preview", {method: "POST",
63+
body: JSON.stringify({goal_id: goalId, provider: target, handoff_mode: mode})});
4864
return read("/api/chat/goal-storage/preview", {method: "POST", body: JSON.stringify({goal_id: goalId, provider: target})});
4965
}
5066
export function recoverGoalStorage(carrier: StorageCarrier, apply = false) {
67+
const saved = storageCarrierSchema.parse(carrier);
68+
if ("operation_id" in saved) return read(`/api/chat/goal-storage/import/${apply ? "apply" : "recover"}`, {
69+
method: "POST", body: JSON.stringify({...saved, ...(apply ? {writers_stopped: true} : {})})});
5170
return read(`/api/chat/goal-storage/${apply ? "apply" : "recover"}`, {method: "POST", body: JSON.stringify(storageCarrierSchema.parse(carrier))});
5271
}

‎apps/presentation/dashboard/src/features/personal-workspace/goal-storage-settings.tsx‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
1313
const [carrier, setCarrier] = useState<StorageCarrier | null>(null);
1414
const [result, setResult] = useState<StorageResult | null>(null);
1515
const [target, setTarget] = useState<MigrationProvider>("sqlite");
16+
const [mode, setMode] = useState<"" | "soft_claim" | "hard_lease">("");
1617
const [confirmed, setConfirmed] = useState(false);
1718
const [busy, setBusy] = useState(false);
1819
const [error, setError] = useState<string | null>(null);
@@ -22,7 +23,7 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
2223
const generation = useRef(0);
2324
useEffect(() => {
2425
const token = ++generation.current;
25-
setCurrent(null); setCold(undefined); setResult(null); setCarrier(null); setConfirmed(false); setInvalidSaved(false); setError(null); setBusy(true);
26+
setCurrent(null); setCold(undefined); setResult(null); setCarrier(null); setConfirmed(false); setMode(""); setInvalidSaved(false); setError(null); setBusy(true);
2627
let saved: StorageCarrier | null = null;
2728
try {
2829
const raw = localStorage.getItem(key);
@@ -54,11 +55,13 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
5455
}, [goalId, key, reload, t]);
5556

5657
async function submit(apply: boolean) {
57-
if (inFlight.current || busy || invalidSaved || (apply && (!carrier || !confirmed))) return;
58+
if (inFlight.current || busy || invalidSaved || (apply && (!carrier || !confirmed)) ||
59+
(!apply && !current?.canonical && !mode)) return;
5860
inFlight.current = true; setBusy(true); setError(null);
5961
const token = generation.current;
6062
try {
61-
const next = apply ? await recoverGoalStorage(carrier!, true) : await previewGoalStorage(goalId, target);
63+
const next = apply ? await recoverGoalStorage(carrier!, true) : await previewGoalStorage(goalId, target,
64+
current?.canonical ? undefined : mode || undefined);
6265
if (token !== generation.current) return;
6366
setResult(next);
6467
if (!apply && next.ok) {
@@ -87,7 +90,9 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
8790
catch { setError(t("storage.savedInvalid")); return; }
8891
setCarrier(null); setResult(null); setConfirmed(false); setInvalidSaved(false); setError(null);
8992
}
90-
const completed = result?.recovery?.phase === "completed";
93+
const coldCarrier = carrier && "operation_id" in carrier;
94+
const completed = result?.recovery?.phase === "completed" || (coldCarrier &&
95+
(result?.status === "applied" || result?.status === "recovered" || result?.status === "replayed"));
9196
return <section className="personal-cadence-settings" aria-label={t("storage.title")}>
9297
<div className="personal-cadence-form">
9398
<h3>{t("storage.title")}</h3>
@@ -103,28 +108,33 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
103108
<p>{t("storage.coldBoundary")}</p>
104109
</>}
105110
</div> : null}
106-
{current?.canonical || carrier ? <>
107-
{carrier ? <p>{t("storage.reviewed", {source: result?.reviewed_source?.provider ?? "?", target: result?.target_provider ?? "?", cursor: result?.reviewed_source?.cursor ?? "?"})}</p>
111+
{current || carrier ? <>
112+
{carrier ? <p>{coldCarrier ? t("storage.coldReviewed", {target: result?.target_provider ?? "?", mode: result?.target_handoff_mode ? t(`ownership.${result.target_handoff_mode}`) : "?"}) : t("storage.reviewed", {source: result?.reviewed_source?.provider ?? "?", target: result?.target_provider ?? "?", cursor: result?.reviewed_source?.cursor ?? "?"})}</p>
108113
: <label>{t("storage.target")}<select aria-label={t("storage.target")} value={target} disabled={busy} onChange={e => setTarget(e.target.value as MigrationProvider)}>
109114
<option value="sqlite">SQLite</option><option value="file">File</option></select></label>}
110-
{carrier && !completed ? <label><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{t("storage.confirm")}</label> : null}
115+
{!carrier && !current?.canonical ? <label>{t("ownership.target")}<select aria-label={t("ownership.target")} value={mode} disabled={busy} onChange={e => setMode(e.target.value as typeof mode)}>
116+
<option value="" disabled>{t("storage.choosePolicy")}</option>
117+
<option value="soft_claim">{t("ownership.soft_claim")}</option><option value="hard_lease">{t("ownership.hard_lease")}</option>
118+
</select></label> : null}
119+
{result?.source_inventory ? <p>{t("storage.coldInventory", {todos: result.source_inventory.todo_count, archived: result.source_inventory.archived_todo_count, leases: result.source_inventory.lease_count})}</p> : null}
120+
{carrier && !completed ? <label><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{t(coldCarrier ? "storage.coldConfirm" : "storage.confirm")}</label> : null}
111121
<div className="personal-cadence-actions">
112-
{carrier ? <><button className="is-primary" disabled={busy || !confirmed || completed || !result?.ok} onClick={() => void submit(true)} type="button">{t("storage.apply")}</button>
122+
{carrier ? <><button className="is-primary" disabled={busy || !confirmed || completed || !result?.ok} onClick={() => void submit(true)} type="button">{t(coldCarrier ? "storage.coldApply" : "storage.apply")}</button>
113123
<button disabled={busy} onClick={discard} type="button">{t("storage.fresh")}</button></>
114-
: <button className="is-primary" disabled={busy || invalidSaved || !current?.provider || target === current.provider} onClick={() => void submit(false)} type="button">{t("storage.preview")}</button>}
124+
: <button className="is-primary" disabled={busy || invalidSaved || (current?.canonical ? !current.provider || target === current.provider : !mode)} onClick={() => void submit(false)} type="button">{t(current?.canonical ? "storage.preview" : "storage.coldPreview")}</button>}
115125
</div>
116126
</> : null}
117127
<div className="personal-cadence-actions">
118128
{invalidSaved ? <button disabled={busy} onClick={discard} type="button">{t("storage.fresh")}</button> : null}
119129
<button disabled={busy} onClick={() => setReload(n => n + 1)} type="button">{t(carrier ? "storage.recover" : "storage.refresh")}</button>
120130
</div>
121-
{completed ? <p role="status">{t("storage.completed")}</p> : null}
122-
{result?.recovery?.phase === "prepared" ? <p role="status">{t("storage.prepared")}</p> : null}
131+
{completed ? <p role="status">{t(coldCarrier ? "storage.coldCompleted" : "storage.completed")}</p> : null}
132+
{result?.recovery?.phase === "prepared" || (coldCarrier && result?.status === "prepared") ? <p role="status">{t("storage.prepared")}</p> : null}
123133
{error ? <p className="personal-machine-error" role="alert">{error}</p> : null}
124134
{result?.reason_code ? <p><code>{result.reason_code}</code></p> : null}
125135
{current?.canonical || carrier ? <details><summary>{t("storage.details")}</summary>
126136
{current?.canonical ? <p>{current.store_identity} · {current.provider_revision} · {current.cursor}</p> : null}
127-
{carrier ? <p>{carrier.preview_id} · {carrier.plan_sha256}</p> : null}
137+
{carrier ? <p>{"operation_id" in carrier ? carrier.operation_id : carrier.preview_id} · {carrier.plan_sha256}</p> : null}
128138
</details> : null}
129139
{busy ? <p aria-live="polite">{t("common.loading")}</p> : null}
130140
</div>

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ const en = {
2020
"storage.coldCounts": "{active} active tasks · {archived} archived tasks · {leases} unsettled leases",
2121
"storage.coldCapture": "Original capture files observed; history retained.",
2222
"storage.coldOutbox": "Outbox files observed; their processing is unverified.",
23-
"storage.coldBoundary": "Old Markdown source inspected. Import is not available here yet: writer/Host stop, lease settlement, outbox disposition and a backup-bound import still need verification. Nothing was captured, migrated or granted execution authority.",
23+
"storage.coldBoundary": "Import this previous Markdown Goal from a verified private local backup. This does not stop Hosts, settle leases or dispose of capture/outbox work for you.",
24+
"storage.choosePolicy": "Choose the execution policy",
25+
"storage.coldReviewed": "Reviewed Markdown source → {target}; execution policy: {mode}. Apply rechecks the original source and backup.",
26+
"storage.coldInventory": "Reviewed inventory: {todos} tasks, including {archived} archived · {leases} retained lease records",
27+
"storage.coldConfirm": "I stopped all writers and Hosts, settled leases and disposed of capture/outbox work. Import this reviewed source.",
28+
"storage.coldApply": "Import reviewed Markdown source",
29+
"storage.coldPreview": "Back up and preview import",
30+
"storage.coldCompleted": "The original import receipt is verified. Current storage is read independently above. Retained leases and receipts grant no new execution authority.",
2431
"storage.reviewed": "Reviewed source: {source}, cursor {cursor} → {target}. A changed source rejects apply.",
2532
"storage.confirm": "I stopped writers and settled leases. I confirm this reviewed storage change.",
2633
"storage.apply": "Back up and switch storage",
@@ -43,7 +50,7 @@ const en = {
4350
"ownership.unpromoted": "Not on canonical storage",
4451
"storage.oldSource": "Old Markdown source",
4552
"storage.readUnavailable": "Current storage could not be read. Try reading it again.",
46-
"ownership.promoteFirst": "This Goal has no canonical store. Ownership changes require a reviewed storage import; this policy form does not perform it.",
53+
"ownership.promoteFirst": "Use Data storage below to review and import this Markdown Goal first. This policy form does not migrate storage.",
4754
"ownership.target": "New policy",
4855
"ownership.softHelp": "Coordinate assignment without requiring an exclusive execution lease. Active leases must be settled first.",
4956
"ownership.hardHelp": "Ownership changes and completion require the task’s original execution lease.",
@@ -1368,7 +1375,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
13681375
"storage.coldCounts": "{active} 项当前任务 · {archived} 项归档任务 · {leases} 项未结算 lease",
13691376
"storage.coldCapture": "已发现原 capture 文件,历史原样保留。",
13701377
"storage.coldOutbox": "已发现 outbox 文件,处理结果尚未验证。",
1371-
"storage.coldBoundary": "已盘点旧 Markdown 源。这里尚不能导入:仍需验证 writer/Host 停止、lease 结算、outbox 处置与备份绑定的导入。此次未生成 capture、迁移数据或授予执行权限。",
1378+
"storage.coldBoundary": "从已验证的本机私有备份导入这个旧 Markdown Goal。此操作不会替你停止 Host、结算 lease 或处置 capture/outbox。",
1379+
"storage.choosePolicy": "请选择执行策略",
1380+
"storage.coldReviewed": "已审核 Markdown 来源 → {target};执行策略:{mode}。导入时会复核原来源与备份。",
1381+
"storage.coldInventory": "已审核盘点:{todos} 项任务(含 {archived} 项归档)· {leases} 条保留租约记录",
1382+
"storage.coldConfirm": "我已停止全部写入方和 Host、结算 lease 并处置 capture/outbox。确认导入这份审核来源。",
1383+
"storage.coldApply": "导入已审核的 Markdown 来源",
1384+
"storage.coldPreview": "备份并预览导入",
1385+
"storage.coldCompleted": "原导入回执已核验。上方当前存储独立读回;保留的租约和回执不授予新的执行权限。",
13721386
"storage.reviewed": "已审核来源:{source},游标 {cursor} → {target}。来源变化时拒绝应用。",
13731387
"storage.confirm": "我已停止写入方并结算 lease,确认此预览的存储变更。",
13741388
"storage.apply": "备份并切换存储",
@@ -1391,7 +1405,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
13911405
"ownership.unpromoted": "尚未使用统一状态存储",
13921406
"storage.oldSource": "旧 Markdown 来源",
13931407
"storage.readUnavailable": "未能读取当前存储,请重试读回。",
1394-
"ownership.promoteFirst": "此 Goal 尚无 canonical 存储。变更所有权需要先完成经过核对的存储导入;此策略表单不执行导入。",
1408+
"ownership.promoteFirst": "请先在下方“数据存储”中审核并导入此 Markdown Goal。此策略表单不迁移存储。",
13951409
"ownership.target": "新策略",
13961410
"ownership.softHelp": "协调任务归属,不要求独占执行租约。仍在运行的租约必须先结算。",
13971411
"ownership.hardHelp": "变更所有权和完成任务需要持有该任务原有的执行租约。",

‎docs/architecture/rfcs/loopx-overall-roadmap-v0.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,34 @@ provider. This is a bounded App companion, not full old-source/Host upgrade or
930930
D2/release-default qualification. Continue those original acceptance frontiers
931931
and retire each last caller separately.
932932

933+
Cold-source import now has a bounded CLI/App coordination stage: complete source
934+
records, an immutable source/target carrier bound to actual backup member bytes,
935+
explicit operator shutdown attestation, revalidation before the durable writer
936+
fence, and original-receipt recovery through the existing File/SQLite owners.
937+
It refuses unresolved capture/outbox and unsettled leases, including expired
938+
active and orphan records, without manufacturing shadow qualification. A killed
939+
fenced process can resume without rereading Markdown; original-receipt replay
940+
preserves later canonical writes. Coordination-source backup verification
941+
**does not qualify complete Goal recovery**. Packaged Goal storage settings
942+
reuse that transaction for private backup, inventory, explicit policy/stop
943+
confirmation and original-operation readback. Reload is read-only, including a
944+
fenced but uncommitted operation; applying the original carrier requires fresh
945+
confirmation. File/SQLite HTTP qualification preserves later writes and refuses
946+
source/backup drift. The operator-led POSIX stop path now exercises actual owned
947+
Host processes and native source leases on File/SQLite: process exit and lease
948+
release remain separate, expired active leases refuse import, and the old grant
949+
cannot launch a Host after cutover. This proves the existing supervisor/lease
950+
boundary with synthetic work, not automatic Host discovery or live model use.
951+
Continue pending outbox disposition, App loading with the old normal writer
952+
absent and independent full-backup recovery in R5. The
953+
installed cold-import CLI uses the existing selected dispatcher and
954+
Goal path resolver; real File/SQLite import and original-receipt recovery pass
955+
with the four old normal producer modules physically absent in a disposable
956+
package. This qualifies that command's loading boundary, not every other CLI
957+
caller or removal of those modules. Keep T4 retirement on actual callers: the
958+
retained Python prose-write guard still serves live callers and its obligation
959+
must survive adapter removal. This partial stage does not retire the supported
960+
old writer or qualify a released default.
933961
Cold-import preservation checkpoint (R5/D1, T4/C1): full-state backups now
934962
witness each saved regular member's bytes in their existing manifests, including
935963
raw Markdown history, lease/receipt files, SQLite snapshots and stored

‎docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,15 @@ L3 检查点:独立领取/接管、原子 claim 准入与维护共用 typed le
612612
- **退出:** 按 shared-authority 7.2 分别决定有界改动、可回退自愿 cohort、发布默认值,各自在适用范围具备真实 CLI/backend、独立基线、负例和恢复证据。正式 D2 保留适用容量及至少十日证据,cohort 不必等该证书。D3 保留明确切换权限。本计划没有启动 soak 或晋升 provider。
613613
- **回滚:** 按已审阅的 fenced export/import 和 schema-aware downgrade,不能靠替换二进制恢复旧写权威。
614614

615+
冷旧 Goal 导入复用既有 TS source/promotion/receipt owner,CLI/App 使用绑定实际
616+
备份字节的预览、明确确认、停写 fence 与原操作恢复。POSIX 人工停止旅程现以真实
617+
owned Host 进程、未晋升源的原生租约和 File/SQLite 验证:进程退出不释放租约,
618+
过期 active 租约仍拒绝导入;原生释放后保留历史身份,切换后旧 grant 在 Host 启动前
619+
拒绝。这是合成工作对既有 supervisor/lease 边界的验证,不是自动发现/停止 Host
620+
或 live 模型验收。pending outbox 逐项处置、旧正常 writer 物理缺席的 App 加载、
621+
完整 Goal 历史与备份恢复继续开放;协调源备份验证不结算完整恢复。详见
622+
[冷源导入与支持边界](../../reference/local-authority-provider-selection.md)。
623+
615624
冷旧源保留检查点:本项目全量备份现盘点注册的自定义状态与来源 registry 路由。
616625
真实 CLI 备份和独立静态解包保留完整 Markdown 字节、未引用的归档 Todo 与 runtime
617626
原始历史,包含 SQLite snapshot。这只修复路由遗漏,不代表审核式冷导入或完整状态

0 commit comments

Comments
 (0)