mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
a3a222c6c0
* fix(web): drop the border around the composer "+" trigger
The base button{} rule applied a 1px border to the ComposerPlusMenu
trigger, leaving a visible box around the home composer's "+" icon. Make
the trigger transparent (border + background) with a subtle hover, so it
reads as a plain icon button consistent with the project composer.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
* feat(web): defer media generation settings to in-task AI questions
HyperFrames / Image / Video / Audio composer modes no longer surface
inline pre-flight dropdowns (aspect ratio, duration, model, resolution,
audio kind). Image/Video keep only the design-system picker; HyperFrames
and Audio keep none. Those settings are now asked for by the agent during
the run via the existing question-form / AskUserQuestion flow, mirroring
how Prototype and Slide deck already defer their settings.
- footerInputNamesForChip: image/video -> ['designSystem'], hyperframes/
audio -> [].
- metadataForHomeMediaComposer no longer seeds imageAspect/videoAspect/
videoLength/audioKind/audioDuration; only kind (+ the hyperframes-html
route discriminator) and any picked prompt template.
- queryTemplateForSurface drops the {{ratio}}/{{duration}}/{{model}}/
{{resolution}}/{{voice}} slots from the prompt body.
- system.ts: image now prints "(unknown - ask: ...)" for model/aspect
instead of silently defaulting, so the agent asks (video/audio already
did).
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
* fix(daemon): don't mark a run failed when it produced an artifact
classifyChatRunCloseStatus decided run status purely from the process
exit (code/signal/turnCompletedCleanly) and never checked whether the run
actually produced an artifact. A non-zero exit during teardown (e.g. a
SessionEnd hook) after the deliverable was already written showed a red
"failed" card for work that succeeded.
Add an artifact-aware carve-out: a non-zero NORMAL exit (real exit code,
never a signal) that produced a confirmed artifact this run is classified
succeeded. Gated on code != null && code !== 0 so a signal kill
(OOM / external kill / container shutdown) is never flipped, preserving
the existing guard. The signal comes from scanRunEventsForRetrySideEffects
(artifactWriteSeen || liveArtifactSeen).
Also add a one-shot backfill script (pnpm backfill:failed-runs) that
repairs existing rows: failed runs whose project dir contains an
*.artifact.json are corrected to succeeded.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
* fix(web): persist the entry nav-rail expanded state
The rail's open/collapsed state lived only in EntryShell's useState, so it
reset to collapsed whenever EntryShell remounted — e.g. returning to home
after visiting a project, or a reload. Persist it to localStorage
(SSR-guarded read/write; App is loaded with ssr:false so there's no
hydration mismatch) so the rail keeps its state across navigation and
reloads.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
* fix(web): collapse all entry sections into the single leftmost tab
Clicking a sidebar section (Projects / Automation / Design systems /
Plugins / Integrations) opened a second workspace tab instead of reusing
the leftmost one. Treat the leftmost tab as a single entry-tab singleton
(any view) rather than a Home-view singleton: navigating to any entry view
switches that one tab's view in place, and its label follows the view.
Project / marketplace tabs are unchanged and still open to the right.
- syncStateToRoute: any kind:'home' route reuses the entry tab found by
kind alone and updates its view; project-route guard broadened so
opening a project from any entry view appends rather than replaces.
- normalizeTabsState: dedup / must-exist / leftmost-pin invariants keyed
by kind==='entry' instead of view==='home'.
- The non-closable / pinned / drag-pin / new-tab-reuse sites switch from
view==='home' to kind==='entry'.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
* fix(scripts): correlate backfill repair to the run that produced the artifact
The failed-runs backfill was scoped only by projectId: a single
*.artifact.json anywhere under <dataDir>/projects/<id> flipped every
failed message row in that project to succeeded. Project dirs are
long-lived and accumulate sidecars from unrelated runs, so one
successful artifact reclassified unrelated real failures — an
irreversible data-integrity bug once run without --dry-run.
Now a row is repaired only when the failed message's own
produced_files_json lists a file whose <name>.artifact.json sidecar
still exists on disk, mirroring the daemon's artifactProducedThisRun
carve-out. Path-traversal/absolute refs are rejected.
Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)
* fix(web): strip deferred media defaults from the forwarded run inputs
Hiding the media footer pills (model / ratio / resolution / duration /
audioType / voice) stopped them rendering but did not stop their seeded
defaults from reaching the run. buildHomeMediaComposer still seeds
`ratio: 16:9`, `duration: 5`, `audioType: speech`, etc., and submit()
forwarded submittedActive.inputs as pluginInputs after stripping only
fidelity / slideCount / speakerNotes — so an Image/Video/Audio/HyperFrames
run arrived with baked-in defaults and the first-turn AskUserQuestion
discovery flow had nothing left to ask.
Extend ARTIFACT_FOOTER_FIELD_NAMES to cover the deferred media fields, and
split submit() into apply-inputs vs forwarded-inputs: the plugin is still
(re)applied with the full inputs (od-media-generation validates `subject`),
while only the stripped set is forwarded to onSubmit. Computing
inputsEqual against the apply-inputs avoids a spurious re-apply round-trip
on submit. subject / style / aspect / mediaKind are intentionally kept.
Red spec: tests/components/HomeView.media-options.test.tsx "strips deferred
media settings from the forwarded pluginInputs" — red on the pre-fix
branch (pluginInputs.model === doubao-seedance-2-0-260128), green here.
Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)
* fix(web): assert prototype fidelity deferral on the forwarded run inputs
The deferred-media-defaults change re-applies the plugin with its full
inputs (hyperframes needs `model` to pick its pipeline, od-media-generation
needs `subject`), so the prototype apply call now legitimately carries
`fidelity`. HomeView.prefill's "binds the Home rail Prototype chip" case
still asserted `fidelity` was absent from the apply-call body, which is the
wrong surface — the deferral invariant is that `fidelity` must not reach the
run, not that it is stripped before apply.
Move the assertion onto the forwarded `pluginInputs` from onSubmit, where the
deferral actually holds, and note in HomeView why hyperframes keeps `model`
at apply time. No source behavior change.
Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)
* fix(web): strip deferred media defaults from the run-facing snapshot too
Stripping the deferred footer/media fields from onSubmit.pluginInputs was
not enough: submit() still re-applied the plugin from the FULL inputs and
forwarded that snapshot's id as appliedPluginSnapshotId. The daemon renders
`## Plugin inputs` verbatim from snapshot.inputs (server.ts pluginPromptBlock,
packages/contracts plugin-block.ts) and tells the agent not to re-ask about
anything listed there, so an Image/Video/Audio/HyperFrames run still arrived
with `ratio: 16:9` / `duration: 5` / `model: …` baked into the prompt and the
first-turn AskUserQuestion discovery flow stayed suppressed.
Resolve the run-facing snapshot from the deferral-stripped inputs and compare
inputsEqual against the cached snapshot's inputs so the strip forces a fresh
apply. Stripping only removes non-required fields (subject / style / aspect /
mediaKind survive, and no scenario plugin requires a stripped field), so the
od-media-generation apply still validates.
Regression at the prompt/run boundary: tests/components/HomeView.media-options
"resolves the run-facing snapshot from inputs with the deferred media settings
stripped" asserts the apply-call body that yields appliedPluginSnapshotId (the
source of snapshot.inputs) carries no deferred field while subject survives.
Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)
---------
Co-authored-by: qiongyu1999 <2694684348@qq.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
186 lines
6.9 KiB
JavaScript
186 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// One-shot backfill: fix project runs that produced an artifact on disk but
|
|
// were mis-recorded as `failed` (run_status='failed') because the agent
|
|
// process exited non-zero during teardown after the deliverable already
|
|
// landed. See classifyChatRunCloseStatus in apps/daemon/src/server.ts — going
|
|
// forward the daemon classifies these as `succeeded`; this script repairs the
|
|
// rows written before that fix.
|
|
//
|
|
// A row is repaired ONLY when the failed message itself recorded a produced
|
|
// file (messages.produced_files_json) whose `*.artifact.json` sidecar still
|
|
// exists on disk under <dataDir>/projects/<id>/. This correlates the repair to
|
|
// the specific message/run that emitted the artifact — NOT to project-wide
|
|
// artifact existence. Project directories are long-lived and accumulate
|
|
// sidecars from many unrelated conversations/runs, so flipping every failed
|
|
// row in a project on the strength of one unrelated artifact would reclassify
|
|
// genuine failures (an irreversible data-integrity bug). Mirrors the daemon's
|
|
// `artifactProducedThisRun` carve-out: the artifact must belong to THIS run.
|
|
//
|
|
// Usage:
|
|
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --dry-run
|
|
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --data-dir /path/to/.od --dry-run
|
|
// node --experimental-strip-types scripts/backfill-failed-runs-with-artifacts.ts --data-dir /path/to/.od
|
|
//
|
|
// STOP THE DAEMON FIRST so the WAL is flushed and this does not race a write.
|
|
// --dry-run prints the rows it would flip without writing. Re-running is
|
|
// idempotent (only rows still 'failed' are touched).
|
|
|
|
import { createRequire } from 'node:module';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(HERE, '..');
|
|
|
|
interface BackfillDatabase {
|
|
prepare: (sql: string) => {
|
|
all: (...args: unknown[]) => unknown[];
|
|
run: (...args: unknown[]) => { changes: number };
|
|
};
|
|
transaction: <T>(fn: (arg: T) => number) => (arg: T) => number;
|
|
close: () => void;
|
|
}
|
|
|
|
// better-sqlite3 is a native module owned by the daemon workspace; resolve it
|
|
// from there (matching scripts/seed-test-projects.ts) rather than the root.
|
|
function loadBetterSqlite(): new (filename: string) => BackfillDatabase {
|
|
const daemonRequire = createRequire(path.join(REPO_ROOT, 'apps', 'daemon', 'package.json'));
|
|
return daemonRequire('better-sqlite3') as new (filename: string) => BackfillDatabase;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): { dataDir: string; dryRun: boolean } {
|
|
let dataDir = process.env.OD_DATA_DIR?.trim() || './.od';
|
|
let dryRun = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === undefined) continue;
|
|
if (arg === '--dry-run') dryRun = true;
|
|
else if (arg === '--data-dir') {
|
|
const next = argv[i + 1];
|
|
if (!next) throw new Error('--data-dir requires a path');
|
|
dataDir = next;
|
|
i++;
|
|
} else if (arg.startsWith('--data-dir=')) {
|
|
dataDir = arg.slice('--data-dir='.length);
|
|
}
|
|
}
|
|
return { dataDir: path.resolve(dataDir), dryRun };
|
|
}
|
|
|
|
// A ProjectFile entry as persisted in messages.produced_files_json. Only the
|
|
// name/path is needed to locate its sidecar; everything else is ignored.
|
|
interface ProducedFileRef {
|
|
name?: unknown;
|
|
path?: unknown;
|
|
}
|
|
|
|
function parseProducedFiles(raw: unknown): ProducedFileRef[] {
|
|
if (typeof raw !== 'string' || !raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? (parsed as ProducedFileRef[]) : [];
|
|
} catch {
|
|
return []; // malformed JSON — treat as "no produced files", not fatal
|
|
}
|
|
}
|
|
|
|
// True when THIS message recorded a produced file whose `<name>.artifact.json`
|
|
// sidecar still exists on disk. Resolved per file relative to the project dir,
|
|
// so a sidecar from an unrelated run in the same project never counts.
|
|
function messageProducedArtifact(
|
|
projectsRoot: string,
|
|
projectId: string,
|
|
producedFilesJson: unknown,
|
|
): boolean {
|
|
const files = parseProducedFiles(producedFilesJson);
|
|
if (files.length === 0) return false;
|
|
const dir = path.join(projectsRoot, projectId);
|
|
for (const file of files) {
|
|
const rel = typeof file.path === 'string' && file.path
|
|
? file.path
|
|
: typeof file.name === 'string'
|
|
? file.name
|
|
: undefined;
|
|
if (!rel) continue;
|
|
// Reject path traversal / absolute paths; the sidecar must live inside the
|
|
// project dir next to the produced file.
|
|
const resolved = path.resolve(dir, `${rel}.artifact.json`);
|
|
const dirWithSep = dir.endsWith(path.sep) ? dir : dir + path.sep;
|
|
if (resolved !== dir && !resolved.startsWith(dirWithSep)) continue;
|
|
try {
|
|
if (fs.statSync(resolved).isFile()) return true;
|
|
} catch {
|
|
// sidecar absent — keep checking the message's other produced files
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function main() {
|
|
const { dataDir, dryRun } = parseArgs(process.argv.slice(2));
|
|
const dbPath = path.join(dataDir, 'app.sqlite');
|
|
const projectsRoot = path.join(dataDir, 'projects');
|
|
|
|
if (!fs.existsSync(dbPath)) {
|
|
throw new Error(`No sqlite db at ${dbPath} (is --data-dir correct?)`);
|
|
}
|
|
|
|
const Database = loadBetterSqlite();
|
|
const db = new Database(dbPath);
|
|
try {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT m.id AS messageId,
|
|
c.project_id AS projectId,
|
|
m.produced_files_json AS producedFilesJson
|
|
FROM messages m
|
|
JOIN conversations c ON c.id = m.conversation_id
|
|
WHERE m.run_status = 'failed'`,
|
|
)
|
|
.all() as Array<{ messageId: string; projectId: string; producedFilesJson: unknown }>;
|
|
|
|
const toFlip = rows.filter((row) =>
|
|
messageProducedArtifact(projectsRoot, row.projectId, row.producedFilesJson),
|
|
);
|
|
|
|
console.log(
|
|
`Found ${rows.length} failed run row(s); ${toFlip.length} recorded a produced file with an artifact sidecar on disk.`,
|
|
);
|
|
const byProject = new Map<string, number>();
|
|
for (const row of toFlip) {
|
|
byProject.set(row.projectId, (byProject.get(row.projectId) ?? 0) + 1);
|
|
}
|
|
for (const [projectId, count] of byProject) {
|
|
console.log(` ${projectId} (${count} row${count === 1 ? '' : 's'})`);
|
|
}
|
|
|
|
if (toFlip.length === 0) {
|
|
console.log('Nothing to repair.');
|
|
return;
|
|
}
|
|
|
|
if (dryRun) {
|
|
console.log('\n--dry-run: no changes written. Re-run without --dry-run to apply.');
|
|
return;
|
|
}
|
|
|
|
const update = db.prepare(
|
|
`UPDATE messages SET run_status = 'succeeded' WHERE id = ? AND run_status = 'failed'`,
|
|
);
|
|
const flip = db.transaction((items: Array<{ messageId: string }>) => {
|
|
let changed = 0;
|
|
for (const item of items) {
|
|
changed += update.run(item.messageId).changes;
|
|
}
|
|
return changed;
|
|
});
|
|
const changed = flip(toFlip);
|
|
console.log(`\nRepaired ${changed} row(s): run_status failed -> succeeded.`);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
main();
|