Compare commits

...
10 Commits
40 changed files with 4947 additions and 23 deletions
+350
View File
@@ -0,0 +1,350 @@
.system/.codex-system-skills.marker
.system/imagegen/agents/openai.yaml
.system/imagegen/assets/imagegen-small.svg
.system/imagegen/assets/imagegen.png
.system/imagegen/LICENSE.txt
.system/imagegen/references/cli.md
.system/imagegen/references/codex-network.md
.system/imagegen/references/image-api.md
.system/imagegen/references/prompting.md
.system/imagegen/references/sample-prompts.md
.system/imagegen/scripts/image_gen.py
.system/imagegen/scripts/remove_chroma_key.py
.system/imagegen/SKILL.md
.system/openai-docs/agents/openai.yaml
.system/openai-docs/assets/openai-small.svg
.system/openai-docs/assets/openai.png
.system/openai-docs/LICENSE.txt
.system/openai-docs/references/latest-model.md
.system/openai-docs/references/prompting-guide.md
.system/openai-docs/references/upgrade-guide.md
.system/openai-docs/scripts/resolve-latest-model-info.js
.system/openai-docs/SKILL.md
.system/plugin-creator/agents/openai.yaml
.system/plugin-creator/assets/plugin-creator-small.svg
.system/plugin-creator/assets/plugin-creator.png
.system/plugin-creator/references/plugin-json-spec.md
.system/plugin-creator/scripts/create_basic_plugin.py
.system/plugin-creator/scripts/validate_plugin.py
.system/plugin-creator/SKILL.md
.system/skill-creator/agents/openai.yaml
.system/skill-creator/assets/skill-creator-small.svg
.system/skill-creator/assets/skill-creator.png
.system/skill-creator/license.txt
.system/skill-creator/references/openai_yaml.md
.system/skill-creator/scripts/generate_openai_yaml.py
.system/skill-creator/scripts/init_skill.py
.system/skill-creator/scripts/quick_validate.py
.system/skill-creator/SKILL.md
.system/skill-installer/agents/openai.yaml
.system/skill-installer/assets/skill-installer-small.svg
.system/skill-installer/assets/skill-installer.png
.system/skill-installer/LICENSE.txt
.system/skill-installer/scripts/github_utils.py
.system/skill-installer/scripts/install-skill-from-github.py
.system/skill-installer/scripts/list-skills.py
.system/skill-installer/SKILL.md
deploy-to-vercel/resources/deploy-codex.sh
deploy-to-vercel/resources/deploy.sh
deploy-to-vercel/SKILL.md
claude-hud-statusline/SKILL.md
find-skills/SKILL.md
vercel-cli-with-tokens/SKILL.md
vercel-composition-patterns/AGENTS.md
vercel-composition-patterns/metadata.json
vercel-composition-patterns/README.md
vercel-composition-patterns/rules/_sections.md
vercel-composition-patterns/rules/_template.md
vercel-composition-patterns/rules/architecture-avoid-boolean-props.md
vercel-composition-patterns/rules/architecture-compound-components.md
vercel-composition-patterns/rules/patterns-children-over-render-props.md
vercel-composition-patterns/rules/patterns-explicit-variants.md
vercel-composition-patterns/rules/react19-no-forwardref.md
vercel-composition-patterns/rules/state-context-interface.md
vercel-composition-patterns/rules/state-decouple-implementation.md
vercel-composition-patterns/rules/state-lift-state.md
vercel-composition-patterns/SKILL.md
vercel-optimize/AGENTS.md
vercel-optimize/CONTRIBUTING.md
vercel-optimize/lib/auth-route.mjs
vercel-optimize/lib/budget-summary.mjs
vercel-optimize/lib/citations.mjs
vercel-optimize/lib/cost-coverage.mjs
vercel-optimize/lib/dedup-recs.mjs
vercel-optimize/lib/deep-dive.mjs
vercel-optimize/lib/display-labels.mjs
vercel-optimize/lib/extract-claims.mjs
vercel-optimize/lib/framework-support.mjs
vercel-optimize/lib/gates/build-minutes-fanout.mjs
vercel-optimize/lib/gates/cold-start.mjs
vercel-optimize/lib/gates/contract.mjs
vercel-optimize/lib/gates/cwv-poor.mjs
vercel-optimize/lib/gates/external-api-slow.mjs
vercel-optimize/lib/gates/hard-gates.mjs
vercel-optimize/lib/gates/index.mjs
vercel-optimize/lib/gates/isr-overrevalidation.mjs
vercel-optimize/lib/gates/middleware-heavy.mjs
vercel-optimize/lib/gates/observability-events-attribution.mjs
vercel-optimize/lib/gates/platform-bot-protection.mjs
vercel-optimize/lib/gates/platform-fluid-compute.mjs
vercel-optimize/lib/gates/region-misconfig.mjs
vercel-optimize/lib/gates/route-errors.mjs
vercel-optimize/lib/gates/scanner-driven.mjs
vercel-optimize/lib/gates/select-candidates.mjs
vercel-optimize/lib/gates/slow-route.mjs
vercel-optimize/lib/gates/types.d.ts
vercel-optimize/lib/gates/uncached-route.mjs
vercel-optimize/lib/gates/usage-spike-triage.mjs
vercel-optimize/lib/grade-recommendation.mjs
vercel-optimize/lib/impact-label.mjs
vercel-optimize/lib/impact-magnitude.mjs
vercel-optimize/lib/investigation-brief.mjs
vercel-optimize/lib/project-facts.mjs
vercel-optimize/lib/queries.mjs
vercel-optimize/lib/reconcile-candidates.mjs
vercel-optimize/lib/render-report.mjs
vercel-optimize/lib/repo-root.mjs
vercel-optimize/lib/route-normalize.mjs
vercel-optimize/lib/sanitizers/bot-protection-certainty.mjs
vercel-optimize/lib/sanitizers/cache-tag-invalidation-certainty.mjs
vercel-optimize/lib/sanitizers/count-correct.mjs
vercel-optimize/lib/sanitizers/function-duration-invocations.mjs
vercel-optimize/lib/sanitizers/index.mjs
vercel-optimize/lib/sanitizers/middleware-conflict.mjs
vercel-optimize/lib/sanitizers/missing-citation.mjs
vercel-optimize/lib/sanitizers/pre-release.mjs
vercel-optimize/lib/sanitizers/rate-limit.mjs
vercel-optimize/lib/sanitizers/rendering-mode-mislabel.mjs
vercel-optimize/lib/sanitizers/undeclared-dep.mjs
vercel-optimize/lib/sanitizers/vercel-directive-strip.mjs
vercel-optimize/lib/sanitizers/window-units.mjs
vercel-optimize/lib/scanners/cache-components-suspense-dedupe.mjs
vercel-optimize/lib/scanners/edge-heavy-import.mjs
vercel-optimize/lib/scanners/force-dynamic.mjs
vercel-optimize/lib/scanners/headers-in-page.mjs
vercel-optimize/lib/scanners/index.mjs
vercel-optimize/lib/scanners/large-static-asset.mjs
vercel-optimize/lib/scanners/max-age-without-s-maxage.mjs
vercel-optimize/lib/scanners/middleware-broad-matcher.mjs
vercel-optimize/lib/scanners/missing-cache-headers.mjs
vercel-optimize/lib/scanners/prisma-include-tree.mjs
vercel-optimize/lib/scanners/region-pin-in-config.mjs
vercel-optimize/lib/scanners/source-maps-production.mjs
vercel-optimize/lib/scanners/sveltekit-prerender-missing.mjs
vercel-optimize/lib/scanners/turbo-force-bypass.mjs
vercel-optimize/lib/scanners/unoptimized-image.mjs
vercel-optimize/lib/scanners/use-cache-date-stamp.mjs
vercel-optimize/lib/support-topics.mjs
vercel-optimize/lib/throttle.mjs
vercel-optimize/lib/util.mjs
vercel-optimize/lib/vercel.mjs
vercel-optimize/lib/verify-claim.mjs
vercel-optimize/lib/workspace-resolver.mjs
vercel-optimize/metadata.json
vercel-optimize/README.md
vercel-optimize/references/candidates.md
vercel-optimize/references/data-collection.md
vercel-optimize/references/docs-library.json
vercel-optimize/references/doctrine.md
vercel-optimize/references/observability-plus.md
vercel-optimize/references/playbooks/ai-application.md
vercel-optimize/references/playbooks/api-service.md
vercel-optimize/references/playbooks/content-site.md
vercel-optimize/references/playbooks/ecommerce.md
vercel-optimize/references/playbooks/marketing.md
vercel-optimize/references/playbooks/README.md
vercel-optimize/references/playbooks/saas.md
vercel-optimize/references/playbooks/sveltekit.md
vercel-optimize/references/recommendations.md
vercel-optimize/references/scanner-patterns.md
vercel-optimize/references/scoring.md
vercel-optimize/references/support-topics/astro-edge-middleware-scope.md
vercel-optimize/references/support-topics/astro-output-mode-and-isr.md
vercel-optimize/references/support-topics/auth-preserving-parallelization.md
vercel-optimize/references/support-topics/bot-protection-product-guardrails.md
vercel-optimize/references/support-topics/build-minutes-monorepo-fanout.md
vercel-optimize/references/support-topics/cache-components-static-shell-boundaries.md
vercel-optimize/references/support-topics/cache-components-suspense-dedupe-pitfall.md
vercel-optimize/references/support-topics/cdn-cache-auth-safety.md
vercel-optimize/references/support-topics/cold-start-initialization-bundle.md
vercel-optimize/references/support-topics/core-web-vitals-client-bottlenecks.md
vercel-optimize/references/support-topics/database-egress-pooling-region.md
vercel-optimize/references/support-topics/dynamic-rendering-traps.md
vercel-optimize/references/support-topics/external-api-critical-path-platform.md
vercel-optimize/references/support-topics/external-api-critical-path.md
vercel-optimize/references/support-topics/fast-data-transfer-payloads.md
vercel-optimize/references/support-topics/fluid-compute-caveats.md
vercel-optimize/references/support-topics/function-duration-io-and-after.md
vercel-optimize/references/support-topics/function-invocation-reduction.md
vercel-optimize/references/support-topics/function-region-misconfiguration-ttfb.md
vercel-optimize/references/support-topics/image-optimization-cost-control.md
vercel-optimize/references/support-topics/isr-revalidation-static-generation.md
vercel-optimize/references/support-topics/middleware-proxy-edge-cost.md
vercel-optimize/references/support-topics/next-fetch-revalidate-floor.md
vercel-optimize/references/support-topics/next-font-cls-self-hosting.md
vercel-optimize/references/support-topics/next-heavy-ui-lazy-load-boundaries.md
vercel-optimize/references/support-topics/next-image-lcp-preload-sizes.md
vercel-optimize/references/support-topics/next-route-handler-get-cache-defaults.md
vercel-optimize/references/support-topics/next-script-third-party-strategy.md
vercel-optimize/references/support-topics/nextjs-version-cache-semantics.md
vercel-optimize/references/support-topics/not-found-catchall-request-waste.md
vercel-optimize/references/support-topics/nuxt-route-rules-cache-isr.md
vercel-optimize/references/support-topics/observability-events-cost-attribution.md
vercel-optimize/references/support-topics/post-response-work-waituntil.md
vercel-optimize/references/support-topics/README.md
vercel-optimize/references/support-topics/route-error-durable-offload.md
vercel-optimize/references/support-topics/route-error-runtime-limits.md
vercel-optimize/references/support-topics/runtime-cache-reusable-data.md
vercel-optimize/references/support-topics/sveltekit-isr-prerender-safety.md
vercel-optimize/references/support-topics/sveltekit-split-cold-start-tradeoff.md
vercel-optimize/references/support-topics/usage-spike-triage.md
vercel-optimize/references/support-topics/use-cache-date-stamp-isr-write-amplifier.md
vercel-optimize/references/support-topics/use-cache-remote-shared-origin-data.md
vercel-optimize/references/verification.md
vercel-optimize/references/voice.md
vercel-optimize/scripts/budget-summary.mjs
vercel-optimize/scripts/build-docs.mjs
vercel-optimize/scripts/check-citations.mjs
vercel-optimize/scripts/check-docs-fresh.mjs
vercel-optimize/scripts/collect-signals.mjs
vercel-optimize/scripts/collect-sub-agent-outputs.mjs
vercel-optimize/scripts/deep-dive.mjs
vercel-optimize/scripts/gate-investigations.mjs
vercel-optimize/scripts/merge-signals.mjs
vercel-optimize/scripts/prepare-investigation-brief.mjs
vercel-optimize/scripts/reconcile-candidates.mjs
vercel-optimize/scripts/render-report.mjs
vercel-optimize/scripts/scan-codebase.mjs
vercel-optimize/scripts/verify-and-regen.mjs
vercel-optimize/scripts/verify-finding.mjs
vercel-optimize/SKILL.md
vercel-react-best-practices/AGENTS.md
vercel-react-best-practices/metadata.json
vercel-react-best-practices/README.md
vercel-react-best-practices/rules/_sections.md
vercel-react-best-practices/rules/_template.md
vercel-react-best-practices/rules/advanced-effect-event-deps.md
vercel-react-best-practices/rules/advanced-event-handler-refs.md
vercel-react-best-practices/rules/advanced-init-once.md
vercel-react-best-practices/rules/advanced-use-latest.md
vercel-react-best-practices/rules/async-api-routes.md
vercel-react-best-practices/rules/async-cheap-condition-before-await.md
vercel-react-best-practices/rules/async-defer-await.md
vercel-react-best-practices/rules/async-dependencies.md
vercel-react-best-practices/rules/async-parallel.md
vercel-react-best-practices/rules/async-suspense-boundaries.md
vercel-react-best-practices/rules/bundle-analyzable-paths.md
vercel-react-best-practices/rules/bundle-barrel-imports.md
vercel-react-best-practices/rules/bundle-conditional.md
vercel-react-best-practices/rules/bundle-defer-third-party.md
vercel-react-best-practices/rules/bundle-dynamic-imports.md
vercel-react-best-practices/rules/bundle-preload.md
vercel-react-best-practices/rules/client-event-listeners.md
vercel-react-best-practices/rules/client-localstorage-schema.md
vercel-react-best-practices/rules/client-passive-event-listeners.md
vercel-react-best-practices/rules/client-swr-dedup.md
vercel-react-best-practices/rules/js-batch-dom-css.md
vercel-react-best-practices/rules/js-cache-function-results.md
vercel-react-best-practices/rules/js-cache-property-access.md
vercel-react-best-practices/rules/js-cache-storage.md
vercel-react-best-practices/rules/js-combine-iterations.md
vercel-react-best-practices/rules/js-early-exit.md
vercel-react-best-practices/rules/js-flatmap-filter.md
vercel-react-best-practices/rules/js-hoist-regexp.md
vercel-react-best-practices/rules/js-index-maps.md
vercel-react-best-practices/rules/js-length-check-first.md
vercel-react-best-practices/rules/js-min-max-loop.md
vercel-react-best-practices/rules/js-request-idle-callback.md
vercel-react-best-practices/rules/js-set-map-lookups.md
vercel-react-best-practices/rules/js-tosorted-immutable.md
vercel-react-best-practices/rules/rendering-activity.md
vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md
vercel-react-best-practices/rules/rendering-conditional-render.md
vercel-react-best-practices/rules/rendering-content-visibility.md
vercel-react-best-practices/rules/rendering-hoist-jsx.md
vercel-react-best-practices/rules/rendering-hydration-no-flicker.md
vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md
vercel-react-best-practices/rules/rendering-resource-hints.md
vercel-react-best-practices/rules/rendering-script-defer-async.md
vercel-react-best-practices/rules/rendering-svg-precision.md
vercel-react-best-practices/rules/rendering-usetransition-loading.md
vercel-react-best-practices/rules/rerender-defer-reads.md
vercel-react-best-practices/rules/rerender-dependencies.md
vercel-react-best-practices/rules/rerender-derived-state-no-effect.md
vercel-react-best-practices/rules/rerender-derived-state.md
vercel-react-best-practices/rules/rerender-functional-setstate.md
vercel-react-best-practices/rules/rerender-lazy-state-init.md
vercel-react-best-practices/rules/rerender-memo-with-default-value.md
vercel-react-best-practices/rules/rerender-memo.md
vercel-react-best-practices/rules/rerender-move-effect-to-event.md
vercel-react-best-practices/rules/rerender-no-inline-components.md
vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md
vercel-react-best-practices/rules/rerender-split-combined-hooks.md
vercel-react-best-practices/rules/rerender-transitions.md
vercel-react-best-practices/rules/rerender-use-deferred-value.md
vercel-react-best-practices/rules/rerender-use-ref-transient-values.md
vercel-react-best-practices/rules/server-after-nonblocking.md
vercel-react-best-practices/rules/server-auth-actions.md
vercel-react-best-practices/rules/server-cache-lru.md
vercel-react-best-practices/rules/server-cache-react.md
vercel-react-best-practices/rules/server-dedup-props.md
vercel-react-best-practices/rules/server-hoist-static-io.md
vercel-react-best-practices/rules/server-no-shared-module-state.md
vercel-react-best-practices/rules/server-parallel-fetching.md
vercel-react-best-practices/rules/server-parallel-nested-fetching.md
vercel-react-best-practices/rules/server-serialization.md
vercel-react-best-practices/SKILL.md
vercel-react-native-skills/AGENTS.md
vercel-react-native-skills/metadata.json
vercel-react-native-skills/README.md
vercel-react-native-skills/rules/_sections.md
vercel-react-native-skills/rules/_template.md
vercel-react-native-skills/rules/animation-derived-value.md
vercel-react-native-skills/rules/animation-gesture-detector-press.md
vercel-react-native-skills/rules/animation-gpu-properties.md
vercel-react-native-skills/rules/design-system-compound-components.md
vercel-react-native-skills/rules/fonts-config-plugin.md
vercel-react-native-skills/rules/imports-design-system-folder.md
vercel-react-native-skills/rules/js-hoist-intl.md
vercel-react-native-skills/rules/list-performance-callbacks.md
vercel-react-native-skills/rules/list-performance-function-references.md
vercel-react-native-skills/rules/list-performance-images.md
vercel-react-native-skills/rules/list-performance-inline-objects.md
vercel-react-native-skills/rules/list-performance-item-expensive.md
vercel-react-native-skills/rules/list-performance-item-memo.md
vercel-react-native-skills/rules/list-performance-item-types.md
vercel-react-native-skills/rules/list-performance-virtualize.md
vercel-react-native-skills/rules/monorepo-native-deps-in-app.md
vercel-react-native-skills/rules/monorepo-single-dependency-versions.md
vercel-react-native-skills/rules/navigation-native-navigators.md
vercel-react-native-skills/rules/react-compiler-destructure-functions.md
vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md
vercel-react-native-skills/rules/react-state-dispatcher.md
vercel-react-native-skills/rules/react-state-fallback.md
vercel-react-native-skills/rules/react-state-minimize.md
vercel-react-native-skills/rules/rendering-no-falsy-and.md
vercel-react-native-skills/rules/rendering-text-in-text-component.md
vercel-react-native-skills/rules/scroll-position-no-state.md
vercel-react-native-skills/rules/state-ground-truth.md
vercel-react-native-skills/rules/ui-expo-image.md
vercel-react-native-skills/rules/ui-image-gallery.md
vercel-react-native-skills/rules/ui-measure-views.md
vercel-react-native-skills/rules/ui-menus.md
vercel-react-native-skills/rules/ui-native-modals.md
vercel-react-native-skills/rules/ui-pressable.md
vercel-react-native-skills/rules/ui-safe-area-scroll.md
vercel-react-native-skills/rules/ui-scrollview-content-inset.md
vercel-react-native-skills/rules/ui-styling.md
vercel-react-native-skills/SKILL.md
vercel-react-view-transitions/AGENTS.md
vercel-react-view-transitions/metadata.json
vercel-react-view-transitions/README.md
vercel-react-view-transitions/references/css-recipes.md
vercel-react-view-transitions/references/implementation.md
vercel-react-view-transitions/references/nextjs.md
vercel-react-view-transitions/references/patterns.md
vercel-react-view-transitions/SKILL.md
web-design-guidelines/SKILL.md
*.mjs
*.pckl
*.pyc
-5
View File
@@ -1,5 +0,0 @@
# This directory is a Syncthing folder marker.
# Do not delete.
folderID: ggxeg-zoyv2
created: 2026-05-09T01:23:05-05:00
@@ -0,0 +1,143 @@
# Updating Existing Local Plugins
Use this reference when a plugin already exists and the request is about updating the plugin during
local development.
All scripts here are specified relative to the skill root. Update the path for running the scripts
depending on your current working directory.
## When To Use This Flow
Use this flow when all of the following are true:
- the plugin already exists locally
- the marketplace entry already points at the plugin source you are editing
- the user wants Codex to see the updated plugin without manually editing marketplace files
If the user still needs the initial plugin entry or marketplace structure created, use the scaffold
flow first and only then switch to this reinstall flow.
## Update Loop
1. Update the plugin manifest to a single Codex cachebuster suffix:
```bash
python3 scripts/update_plugin_cachebuster.py \
<plugin-path>
```
Prefer the default helper behavior here. If you omit `--cachebuster`, the helper uses a UTC
timestamp down to seconds, which is the recommended path for routine local iteration.
Only use a manual cachebuster override when the user explicitly asks for one or when a workflow
outside Codex depends on a specific token:
```bash
python3 scripts/update_plugin_cachebuster.py \
<plugin-path> \
--cachebuster local-20260519-184516
```
2. For the default scaffolded flow, read the marketplace name from the personal marketplace file:
```bash
python3 scripts/read_marketplace_name.py
```
Here, "personal marketplace" means the marketplace whose file is at
`~/.agents/plugins/marketplace.json`. On Windows, use the equivalent path under the user profile.
The helper uses Python's home-directory resolution and prints the marketplace name to use when
constructing the install command.
To read the name from a different marketplace file, pass the path directly:
```bash
python3 scripts/read_marketplace_name.py --marketplace-path <path-to-marketplace.json>
```
3. Reinstall from that marketplace name:
```bash
codex plugin add <plugin-name>@<marketplace-name-from-marketplace-json>
```
The default personal marketplace is discovered implicitly from
`~/.agents/plugins/marketplace.json`. You do not need `codex plugin marketplace add` for that
path, and `codex plugin marketplace list` is not the right check for whether that default
marketplace exists.
4. If the plugin is not using the personal marketplace file, check which configured local
marketplace is actually surfacing that plugin:
```bash
codex plugin list
```
If the plugin is not in the personal marketplace file, confirm which marketplace entry points at
the plugin source you are editing and make sure that marketplace is still local. If it is a
different local marketplace, reinstall from that marketplace name instead of forcing the personal
marketplace flow. If it is not local, stop and help the user resolve the mismatch before
continuing.
5. If the plugin lives in a different confirmed local marketplace, substitute that marketplace
name:
```bash
codex plugin add <plugin-name>@<local-marketplace>
```
6. Prompt the user to use a new thread to try the updated plugin, so that Codex picks up new skills
and tools.
## Cachebuster Policy
- Preserve the existing version prefix and replace only the suffix.
- Treat the preserved prefix as everything before `+`.
- Use the format:
```text
<base-version>+codex.<cachebuster>
```
Examples:
- `0.1.0` → `0.1.0+codex.local-20260519-184516`
- `0.1.0+codex.old-token` → `0.1.0+codex.local-20260519-184516`
- `1.2.3-beta.1+codex.prev` → `1.2.3-beta.1+codex.local-20260519-184516`
- `dev-build+other-tag` → `dev-build+codex.local-20260519-184516`
Replace the existing Codex cachebuster instead of appending another one. Do not keep incrementing
numeric version components just to trigger reinstall behavior.
## Marketplace Rules
- Marketplace manipulation should happen through commands, not by hand-editing `marketplace.json`
or `config.toml` during this update/reinstall flow.
- Prefer the personal marketplace file for the default scaffolded flow.
- Read the personal marketplace name with
`python3 scripts/read_marketplace_name.py` and use the printed value when constructing
`codex plugin add <plugin-name>@<marketplace-name>`.
- For non-default marketplace files, use
`python3 scripts/read_marketplace_name.py --marketplace-path <path-to-marketplace.json>` to read
the name before constructing reinstall commands.
- Do not tell the user to run `codex plugin marketplace add` for the default personal-marketplace
flow. That marketplace is discovered implicitly by Codex.
- If the user specified a different marketplace path, make sure that marketplace is installed
before giving install or reinstall instructions. Non-default marketplace paths are not
discovered implicitly.
- Use `codex plugin list` when the plugin lives in a different configured marketplace and you need
to confirm which marketplace is surfacing that plugin.
- If a non-default local marketplace has not been configured yet, install it with
`codex plugin marketplace add <path-to-marketplace-root>` before telling the user to run
`codex plugin add <plugin-name>@<marketplace-name>`.
- If the plugin is not in the personal marketplace file, confirm that the selected marketplace is
local before telling the user to reinstall from it.
- If the selected marketplace is not local, stop and help the user resolve that mismatch rather
than pretending the normal local reinstall flow applies.
- If the plugin source is not already the source referenced by the chosen marketplace entry, stop
and fix that first. This update flow does not rewrite marketplace entries.
## After Reinstall
After reinstalling, prompt the user to start a new thread for testing. That is the safe boundary for
picking up the updated plugin and its MCP tools.
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Print the top-level marketplace name from any marketplace.json file."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def default_marketplace_path() -> Path:
return Path.home() / ".agents" / "plugins" / "marketplace.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Print the top-level marketplace name from marketplace.json. Defaults to the personal "
"marketplace path under the current home directory."
)
)
parser.add_argument(
"--marketplace-path",
default=str(default_marketplace_path()),
help="Path to marketplace.json",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
marketplace_path = Path(args.marketplace_path).expanduser().resolve()
payload = json.loads(marketplace_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{marketplace_path} must contain a JSON object.")
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.")
print(name.strip())
if __name__ == "__main__":
try:
main()
except Exception as err: # noqa: BLE001 - CLI should surface a single clear message.
print(str(err), file=sys.stderr)
raise SystemExit(1) from err
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Rewrite a local plugin version to a single Codex cachebuster suffix."""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
CACHEBUSTER_PREFIX = "codex"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Rewrite a local plugin's version so it preserves everything before '+' and uses "
"a single +codex.<cachebuster> suffix."
)
)
parser.add_argument("plugin_path", help="Path to the plugin root directory")
parser.add_argument(
"--cachebuster",
help="Optional cachebuster token to embed in the plugin version",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
plugin_root = Path(args.plugin_path).expanduser().resolve()
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
manifest = load_manifest(manifest_path)
version = manifest.get("version")
if not isinstance(version, str) or not version.strip():
raise ValueError(f"{manifest_path} must contain a non-empty string 'version'.")
cachebuster = sanitize_cachebuster(args.cachebuster or default_cachebuster())
next_version = with_cachebuster(version, cachebuster)
manifest["version"] = next_version
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Updated plugin version: {version} -> {next_version}")
def load_manifest(manifest_path: Path) -> dict[str, object]:
if not manifest_path.is_file():
raise FileNotFoundError(f"missing manifest: {manifest_path}")
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{manifest_path} must contain a JSON object.")
return payload
def sanitize_cachebuster(value: str) -> str:
sanitized = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower())
sanitized = re.sub(r"-{2,}", "-", sanitized).strip("-")
if not sanitized:
raise ValueError("Cachebuster must contain at least one letter or digit.")
return sanitized
def default_cachebuster() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
def with_cachebuster(version: str, cachebuster: str) -> str:
version_prefix = version.split("+", 1)[0]
return f"{version_prefix}+{CACHEBUSTER_PREFIX}.{cachebuster}"
if __name__ == "__main__":
try:
main()
except Exception as err: # noqa: BLE001 - CLI should surface a single clear message.
print(str(err), file=sys.stderr)
raise SystemExit(1) from err
+26 -14
View File
@@ -234,7 +234,10 @@ def analyse(
v = r.get(col, "").strip()
if v and v.lower() not in ("x", "z", "nan"):
# If it looks like hex (has a-f OR is exactly 4 hex digits)
if re.match(r"^[0-9a-fA-F]+$", v):
if v.lower().startswith("0x") or (
re.match(r"^[0-9a-fA-F]+$", v)
and any(c in v.lower() for c in "abcdef")
):
return True
break
return False
@@ -259,10 +262,14 @@ def analyse(
i_mean, q_mean = float(np.mean(i_data)), float(np.mean(q_data))
iq = (i_data[:fft_n] - i_mean) + 1j * (q_data[:fft_n] - q_mean)
win = np.hanning(fft_n)
cg = float(np.sum(win))
cg = float(np.sum(win)) # coherent gain (for peak amplitude)
win_power = float(np.sum(win ** 2)) # for PSD normalisation
fft_data = np.fft.fftshift(np.fft.fft(iq * win, n=fft_n))
amp = np.abs(fft_data) / cg
amp = np.abs(fft_data) / cg # peak amplitude (linear, for OBW / peaks)
dbfs = 20.0 * np.log10((amp + np.finfo(np.float64).eps) / full_scale)
# Power spectral density: dBFS/Hz (normalised to full-scale power)
psd = (np.abs(fft_data) ** 2) / (full_scale ** 2 * win_power * fs_hz)
psd_dbfs_hz = 10.0 * np.log10(psd + np.finfo(np.float64).eps)
freq_hz = (np.arange(fft_n) - fft_n / 2.0) * fs_hz / fft_n
freq_mhz = freq_hz / 1e6
@@ -279,6 +286,7 @@ def analyse(
result = dict(
i_data=i_data, q_data=q_data, mag_time=mag_time, i_mean=i_mean, q_mean=q_mean,
fft_n=fft_n, freq_hz=freq_hz, freq_mhz=freq_mhz, amp=amp, dbfs=dbfs,
psd_dbfs_hz=psd_dbfs_hz,
obw90=obw90, obw99=obw99, frac90=frac90, frac99=frac99, peaks=peaks,
fs_hz=fs_hz, full_scale=full_scale, i_col=i_col, q_col=q_col,
input_name=input_path.name, skip=s,
@@ -366,8 +374,9 @@ def _plot(result: dict, xlim_mhz, out_dir: Path, prefix: str, title: str | None)
axes[1].grid(True, alpha=0.3)
axes[1].legend(loc="upper right")
# panel 3 — spectrum
axes[2].plot(freq_mhz, dbfs, color="#1f5a95", linewidth=1.0)
# panel 3 — spectrum (PSD)
psd_dbfs_hz = result["psd_dbfs_hz"]
axes[2].plot(freq_mhz, psd_dbfs_hz, color="#1f5a95", linewidth=1.0)
obw90, obw99 = result["obw90"], result["obw99"]
if obw90 > 0:
axes[2].axvline(-obw90 / 2, color="#ff7f0e", ls="--", lw=1.3,
@@ -377,23 +386,26 @@ def _plot(result: dict, xlim_mhz, out_dir: Path, prefix: str, title: str | None)
axes[2].axvline(-obw99 / 2, color="#9467bd", ls="--", lw=1.3,
label=f"99% OBW: {obw99:.2f} MHz")
axes[2].axvline( obw99 / 2, color="#9467bd", ls="--", lw=1.3)
# annotate top peaks using PSD value at peak bin
for p in result["peaks"][:5]:
if xlim_mhz[0] <= p["freq_mhz"] <= xlim_mhz[1]:
axes[2].plot(p["freq_mhz"], p["dbfs"], "o", color="#c23b22", ms=6)
axes[2].annotate(f"{p['freq_mhz']:.2f} MHz", (p["freq_mhz"], p["dbfs"]),
psd_val = float(psd_dbfs_hz[p["bin"]])
axes[2].plot(p["freq_mhz"], psd_val, "o", color="#c23b22", ms=6)
axes[2].annotate(f"{p['freq_mhz']:.2f} MHz\n{psd_val:.1f} dBFS/Hz",
(p["freq_mhz"], psd_val),
xytext=(0, 8), textcoords="offset points",
ha="center", fontsize=11)
ha="center", fontsize=10)
axes[2].set_title(f"{fft_n}-pt complex FFT, Hann window, DC removed, "
f"fs = {fs_hz/1e6:.2f} MHz, FS ref = {result['full_scale']:.0f}")
axes[2].set_xlabel("Frequency (MHz)")
axes[2].set_ylabel("Magnitude (dBFS)")
axes[2].set_ylabel("PSD (dBFS/Hz)")
axes[2].set_xlim(xlim_mhz)
in_view = (freq_mhz >= xlim_mhz[0]) & (freq_mhz <= xlim_mhz[1])
if np.any(in_view):
y_top = max(-20.0, float(np.ceil(np.max(dbfs[in_view]) / 10) * 10 + 10))
y_top = max(-60.0, float(np.ceil(np.max(psd_dbfs_hz[in_view]) / 10) * 10 + 10))
else:
y_top = 10.0
axes[2].set_ylim(-140, y_top)
y_top = -60.0
axes[2].set_ylim(y_top - 120, y_top)
axes[2].grid(True, alpha=0.3)
axes[2].legend(loc="lower right")
@@ -406,8 +418,8 @@ def _plot(result: dict, xlim_mhz, out_dir: Path, prefix: str, title: str | None)
def _auto_fs(path: Path) -> float:
name = path.stem.lower()
for pat, hz in [("491", 491.52e6), ("245", 245.76e6), ("240m", 245.76e6),
("122", 122.88e6), ("120m", 122.88e6), ("61", 61.44e6),
for pat, hz in [("491", 491.52e6), ("245", 245.76e6), ("240m", 240e6),
("122", 122.88e6), ("120m", 120e6), ("61", 61.44e6),
("250m", 250e6), ("125m", 125e6)]:
if pat in name:
return hz
+219
View File
@@ -0,0 +1,219 @@
---
name: fpga-goldenref-flow
description: >
Run the fpga-goldenref-flow LangGraph design-and-execution workflow for the
project at E:\LangGraph_Design\fpga-goldenref-flow. Drives the compiled graph
in-process (NO langgraph dev server) via a persistent SQLite checkpointer:
submits a run, surfaces the human-review interrupt (plans are summarised in the
Claude conversation for the user to approve/reject), resumes execution, and
displays the final summary — all within the Claude session.
Trigger whenever the user says any of: "run FPGA flow", "start FPGA workflow",
"invoke fpga-goldenref-flow", "run goldenref flow", "启动 FPGA 工作流",
"运行 FPGA 设计流程", "跑一下 FPGA flow", "run the FPGA agent",
"execute the FPGA design pipeline", or any request to interact with the
LangGraph agent inside this project. Also trigger when the user wants to approve
or reject the generated plan, or resume a paused fpga-goldenref-flow run.
---
## 驱动方式:进程内驱动(无 dev server)
工作流是一张 LangGraph 图,由 `scripts\run_fpga_local.py` **在本进程内**驱动
(已移除 langgraph dev server 路径)。直接 `build_graph()` + **SqliteSaver 持久化**
驱动编译好的图,状态存在 `<output_dir>\.fpga_flow_local.sqlite`,按 `thread_id`
持久化,`start`/`resume`/`continue` 各自独立进程调用都能接续。
```powershell
conda run -n langgraph python `
"C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
start --input-dir "E:\<proj>\Notes" --output-dir "E:\<proj>\fpga_runs"
```
为什么是进程内驱动:`langgraph dev` 是**内存存储的开发服务器**,对本项目这种长耗时、
多模块、反复中断续跑的执行不可靠——重启丢线程、run 队列被 cancel 残留堵成永久
`pending`、watchfiles `--reload` 自激。进程内驱动没有这些问题(无队列、无内存丢失、
无 reload),状态全部落在 SQLite。
---
## Project info
| Item | Value |
|---|---|
| Root | `E:\LangGraph_Design\fpga-goldenref-flow` |
| Conda env | `langgraph` (`C:\Users\bowei\.conda\envs\langgraph`) |
| Graph name | `fpga_goldenref_flow` (在 `langgraph.json` 里声明;进程内驱动直接 `build_graph()`) |
| Default input dir | `Notes/` (contains PR00.md, Task00.md) |
| Helper script | `C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py` |
---
## Full workflow
> **入口约束**:下面每一步都通过 helper 脚本 `run_fpga_local.py` 在 `langgraph`
> conda 环境里执行。不要直接 `import` `app.*` 在别处运行,统一走 helper 脚本以保证
> SQLite 持久化与退出码一致。
### Step 1 — Submit a new run
**Before calling the script, determine the two key paths:**
- `--input-dir` — the directory that holds the project's requirement files (e.g. `Notes/`). Use an absolute path or a path relative to the current shell's CWD.
- `--output-dir` — the directory where all generated files (task folders, plan.md, summary.md) and the run's checkpoint DB + session file will be written. **This must be inside the caller's project directory, not inside the workflow development directory.** Use the absolute path to the project being worked on, e.g. `E:\MyProject\fpga_runs`.
Run the helper script using the `langgraph` conda environment:
```powershell
conda run -n langgraph python `
"C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
start `
--input-dir "E:\<project-path>\Notes" `
--output-dir "E:\<project-path>\fpga_runs"
```
Optional flag: `--prompt "additional context"` if the user supplied extra requirements.
> **Rule:** always pass `--output-dir` as an absolute path pointing into the calling project.
> If omitted, files fall back to `<input-dir-parent>/runs` which may or may not be in the project directory.
The script prints `[INFO] Thread : <id>` and `[INFO] Output dir : <path>` (note them
for resume), then node-level progress (`-> node_name`) as the graph runs.
**Exit codes:**
| Code | Meaning |
|---|---|
| 0 | Workflow completed — jump to Step 4 |
| 1 | Error — read the output, diagnose, report to user |
| 2 | An interrupt fired — read the `[INTERRUPT]` payload's `action` and branch (see below) |
| 3 | Codex unavailable — workflow stopped (no downgrade). Resume from checkpoint later with `continue`. |
**Two interrupts fire in order — branch on `action` in the `[INTERRUPT]` payload:**
1. `"action": "select_plan_model"` — first, right after decomposition → **Step 1.5**.
2. `"action": "confirm_plan"` — later, after plans are generated → **Step 2**.
### Step 1.5 — Select plan model (first interrupt)
When the `[INTERRUPT]` payload has `"action": "select_plan_model"`, the workflow has
finished decomposition and is waiting for the user to pick the Claude model used to
**author/revise** the plans (review stays on Codex). The payload includes `default`
(Opus 4.8) and the decomposition overview.
Ask the user which model (use AskUserQuestion), offering **Opus 4.8 (default/recommended)**,
**Fable 5**, **Sonnet 4.6**. Then resume with the choice:
```powershell
conda run -n langgraph python `
"C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
resume --output-dir "E:\<project-path>\fpga_runs" `
--plan-model opus # or: fable | sonnet | a full id like claude-opus-4-8
```
The workflow then generates plans with that model and stops again at the
`confirm_plan` interrupt (exit 2) → go to Step 2.
### Step 2 — Human review (Claude reads plans, asks user)
When the script exits with code 2 (`confirm_plan`) it prints:
- `[INTERRUPT]` — the raw interrupt payload (may include a message)
- `[PLAN] <task_id>: <path>` — absolute paths to generated plan files
- `[REQ] <task_id>: <path>` — absolute paths to requirement files
For each plan path, read the file:
```powershell
Get-Content "<plan_path>" -Encoding utf8
```
Then **summarise the plan for the user** in 3–5 bullet points per task and ask:
> "FPGA 工作流已生成以下设计计划,请确认是否批准继续执行:
> …bullet points…
> **批准(继续执行)还是拒绝(请说明需要修改的地方)?**"
Wait for the user's answer before proceeding to Step 3.
### Step 3 — Resume the workflow
> **并发安全(MANDATORY)**:`resume` 必须绑定到本次运行的工程,绝不能依赖全局会话文件。
> **总是带上 `--output-dir`**(与 `start` 时完全相同的绝对路径),这样多个工程并发时
> 各自的会话文件与 SQLite 互不串。等价地,也可以用 `--thread-id "<start 打印的 Thread id>"`
> 直接指定线程,完全绕过会话文件(最稳)。省略两者只在「同一时间只有一个工程在跑」时才安全。
**If the user approves:**
```powershell
conda run -n langgraph python `
"C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
resume --output-dir "E:\<project-path>\fpga_runs"
```
(Default behaviour is approved — no extra flag needed.)
**If the user rejects:**
```powershell
conda run -n langgraph python `
"C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
resume --output-dir "E:\<project-path>\fpga_runs" `
--rejected --feedback "<user feedback verbatim>"
```
> Use the **same `--output-dir`** you passed at `start` (it is printed on the
> `[INFO] Output dir :` line). Alternatively pass `--thread-id "<id>"` from the
> `[INFO] Thread :` line at start.
After rejection the workflow loops back to plan generation; you will likely hit
exit code 2 again with new plans — repeat Step 2.
> **Codex 不可用 (exit 3)**:若任一 resume/start 以 exit 3 退出,说明 codex(gpt-5.5)
> 额度被占满,工作流已停在断点(状态已存 SQLite,不降级)。等 codex 空出来后从断点续跑:
> ```powershell
> conda run -n langgraph python `
> "C:\Users\bowei\.claude\skills\fpga-goldenref-flow\scripts\run_fpga_local.py" `
> continue --output-dir "E:\<project-path>\fpga_runs"
> ```
### Step 4 — Show results
When the script exits with code 0 it prints `[SUMMARY]` followed by the contents
of `<output-dir>\summary.md`. Present this to the user.
If the user wants to inspect a specific task's plan or requirement:
```
<output-dir>\<task_id>\plan.md
<output-dir>\<task_id>\requirement.md
```
The `output-dir` is the value passed to `--output-dir` at start time (printed in the
`[INFO] Output dir :` line) and is also stored in the session file.
---
## Session state (per-project — concurrency safe)
Between `start` and `resume` the helper writes a small session file **inside the
run's own `output_dir`**, so concurrent projects never share it:
```
<output_dir>\.fpga_flow_session.json
```
It holds `thread_id` and `output_dir`. `resume`/`continue` locate it via the
`--output-dir` you pass (hence Step 3 requires it). It is deleted automatically when
the run completes successfully.
**Isolation guarantees for concurrent projects:**
- Graph state (`MainState`/`FpgaState`, checkpoints) is isolated by `thread_id`
inside that run's own `<output_dir>\.fpga_flow_local.sqlite` — different projects
use different output dirs and different DBs, and cannot see each other's state.
- Generated files are isolated by `output_dir`.
- The session pointer is isolated by living inside each `output_dir`. **Always
pass `--output-dir` (or `--thread-id`) to `resume`** so it never reads another
project's session.
To abandon a paused run and start fresh, delete that project's
`<output_dir>\.fpga_flow_session.json` (and, if you want a clean checkpoint,
`<output_dir>\.fpga_flow_local.sqlite`) before calling `start`.
---
## Troubleshooting
| Symptom | Fix |
|---|---|
| `ModuleNotFoundError` | `conda run -n langgraph pip install langgraph langgraph-checkpoint-sqlite` |
| `No session file found` | Must call `start` before `resume` (or pass `--thread-id`) |
| Empty `[PLAN]` lines | Look in `<output_dir>\` for `plan.md` files under each `<task_id>` folder |
| `interrupted` never advances | Make sure `--rejected` flag is accompanied by `--feedback` text |
| exit 3 (Codex 不可用) | Wait for codex quota, then `continue --output-dir <same path>` from the checkpoint |
| Stale state after editing `app\*` | In-process driver imports fresh each run; just re-run `start`/`continue`. To reset a thread, delete that run's `.fpga_flow_local.sqlite`. |
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
fpga-goldenref-flow workflow runner — IN-PROCESS driver (NO langgraph dev server).
Drives the compiled LangGraph graph directly in this Python process with a
persistent SQLite checkpointer. This avoids the langgraph dev server entirely:
no REST, no in-memory store, no background run-queue / enqueue jams, no
watchfiles reload loop. State persists across start/resume/continue invocations
in <output_dir>/.fpga_flow_local.sqlite, keyed by thread_id.
This is the sole driver for the graph (the langgraph dev server path has been
removed). CLI + exit codes:
start submit a new run; exits 2 at an interrupt
resume answer an interrupt (--plan-model OR approve/--rejected --feedback)
continue continue a thread paused at a normal node boundary (input=None)
Exit codes: 0 complete · 1 error · 2 interrupted (human action needed)
"""
import os
import sys
import json
import uuid
import argparse
import sqlite3
# Drive the graph in-process: import the app package.
FPGA_FLOW_ROOT = r"E:\LangGraph_Design\fpga-goldenref-flow"
if FPGA_FLOW_ROOT not in sys.path:
sys.path.insert(0, FPGA_FLOW_ROOT)
RECURSION_LIMIT = int(os.getenv("FPGA_FLOW_RECURSION_LIMIT", "300"))
def _session_file(output_dir: str = "") -> str:
base = (output_dir or "").strip() or FPGA_FLOW_ROOT
return os.path.join(base, ".fpga_flow_session.json")
def _db_path(output_dir: str) -> str:
base = (output_dir or "").strip() or FPGA_FLOW_ROOT
return os.path.join(base, ".fpga_flow_local.sqlite")
def save_session(thread_id, output_dir):
with open(_session_file(output_dir), "w", encoding="utf-8") as f:
json.dump({"thread_id": thread_id, "output_dir": output_dir, "driver": "local"}, f)
def load_session(output_dir=""):
path = _session_file(output_dir)
if not os.path.exists(path):
print(f"[FAIL] No session file at {path}. Run 'start' first (or pass --thread-id).", flush=True)
sys.exit(1)
with open(path, encoding="utf-8") as f:
return json.load(f)
# ── graph + checkpointer ──────────────────────────────────────────────────────
def _build(output_dir):
"""Compile the graph with a persistent SQLite checkpointer (keeps the conn)."""
from langgraph.checkpoint.sqlite import SqliteSaver
from app.main_graph import build_graph
conn = sqlite3.connect(_db_path(output_dir), check_same_thread=False)
saver = SqliteSaver(conn)
try:
saver.setup() # create checkpoint tables if not present
except Exception:
pass
return build_graph(checkpointer=saver), conn
def _config(thread_id):
return {"configurable": {"thread_id": thread_id}, "recursion_limit": RECURSION_LIMIT}
def _stream(graph, config, payload):
"""Run the graph to its next pause, printing node-level progress."""
for chunk in graph.stream(payload, config, stream_mode="updates"):
if isinstance(chunk, dict):
for node in chunk:
if node and not str(node).startswith("__"):
print(f" -> {node}", flush=True)
def _pending_interrupts(graph, config):
"""Return list of interrupt payload values currently pending (if any)."""
snap = graph.get_state(config)
vals = []
for task in (snap.tasks or ()):
for it in (getattr(task, "interrupts", None) or ()):
v = getattr(it, "value", it)
vals.append(v)
return vals, snap
def _print_interrupt(graph, config):
vals, snap = _pending_interrupts(graph, config)
for v in vals:
print(f"\n[INTERRUPT] {json.dumps(v, indent=2, default=str, ensure_ascii=False)}", flush=True)
# plan/requirement paths from state
values = snap.values or {}
for serial in values.get("serial_tasks", []):
for pt in serial.get("parallel_tasks", []):
tid = pt.get("task_id", "?")
if pt.get("plan_path"):
print(f"[PLAN] {tid}: {pt['plan_path']}", flush=True)
if pt.get("req_path"):
print(f"[REQ] {tid}: {pt['req_path']}", flush=True)
print("\n[ACTION REQUIRED] Claude will read these plans and ask for your approval.", flush=True)
def _show_summary(output_dir):
summary = os.path.join(output_dir, "summary.md")
if os.path.exists(summary):
with open(summary, encoding="utf-8") as f:
print(f"\n[SUMMARY]\n{f.read()}", flush=True)
else:
print(f"[DONE] Workflow completed. (summary not found at {summary})", flush=True)
def _finish(graph, config, output_dir):
"""After a stream returns, classify: interrupted (2) / done (0)."""
vals, snap = _pending_interrupts(graph, config)
if vals:
print("\n[STATUS] interrupted", flush=True)
_print_interrupt(graph, config)
sys.exit(2)
if snap.next:
# paused without an interrupt (rare) — treat as needing continue
print(f"\n[STATUS] paused (next={list(snap.next)})", flush=True)
sys.exit(2)
print("\n[STATUS] success", flush=True)
_show_summary(output_dir)
sf = _session_file(output_dir)
if os.path.exists(sf):
os.remove(sf)
sys.exit(0)
# ── commands ──────────────────────────────────────────────────────────────────
def cmd_start(args):
raw_out = (args.output_dir or "").strip()
output_dir = os.path.abspath(raw_out) if raw_out else os.path.join(
os.path.dirname(os.path.abspath(args.input_dir)), "runs")
os.makedirs(output_dir, exist_ok=True)
thread_id = str(uuid.uuid4())
print(f"[INFO] Driver : in-process (no dev server)", flush=True)
print(f"[INFO] Thread : {thread_id}", flush=True)
print(f"[INFO] Output dir : {output_dir}", flush=True)
print(f"[INFO] Checkpoints : {_db_path(output_dir)}", flush=True)
graph, conn = _build(output_dir)
try:
inp = {"input_dir": args.input_dir, "output_dir": output_dir}
if args.prompt:
inp["user_prompt"] = args.prompt
print("[INFO] Running fpga_flow graph ...\n", flush=True)
save_session(thread_id, output_dir)
_stream(graph, _config(thread_id), inp)
_finish(graph, _config(thread_id), output_dir)
finally:
conn.close()
def _resolve_thread(args):
output_dir = (args.output_dir or "").strip()
thread_id = (getattr(args, "thread_id", "") or "").strip()
if not thread_id:
sess = load_session(output_dir)
thread_id = sess["thread_id"]
output_dir = sess.get("output_dir", output_dir)
return thread_id, output_dir
def cmd_resume(args):
from langgraph.types import Command
thread_id, output_dir = _resolve_thread(args)
graph, conn = _build(output_dir)
try:
plan_model = (getattr(args, "plan_model", "") or "").strip()
if plan_model:
resume_val = {"plan_model": plan_model}
print(f"[INFO] Resuming {thread_id} plan_model={plan_model}", flush=True)
else:
resume_val = {"approved": not args.rejected, "feedback": args.feedback or ""}
print(f"[INFO] Resuming {thread_id} approved={not args.rejected}", flush=True)
print("", flush=True)
_stream(graph, _config(thread_id), Command(resume=resume_val))
_finish(graph, _config(thread_id), output_dir)
finally:
conn.close()
def cmd_continue(args):
thread_id, output_dir = _resolve_thread(args)
graph, conn = _build(output_dir)
try:
print(f"[INFO] Continuing {thread_id} from checkpoint ...\n", flush=True)
_stream(graph, _config(thread_id), None)
_finish(graph, _config(thread_id), output_dir)
finally:
conn.close()
def main():
p = argparse.ArgumentParser(description="fpga-goldenref-flow IN-PROCESS runner (no dev server)")
sub = p.add_subparsers(dest="cmd")
ps = sub.add_parser("start")
ps.add_argument("--input-dir", default="Notes/")
ps.add_argument("--output-dir", default="")
ps.add_argument("--prompt", default="")
pr = sub.add_parser("resume")
pr.add_argument("--plan-model", default="")
pr.add_argument("--rejected", action="store_true", default=False)
pr.add_argument("--feedback", default="")
pr.add_argument("--output-dir", default="")
pr.add_argument("--thread-id", default="")
pc = sub.add_parser("continue")
pc.add_argument("--output-dir", default="")
pc.add_argument("--thread-id", default="")
args = p.parse_args()
if not args.cmd:
p.print_help(); sys.exit(1)
try:
from app.nodes_design import CodexUnavailable
except Exception:
CodexUnavailable = () # type: ignore
try:
if args.cmd == "start":
cmd_start(args)
elif args.cmd == "resume":
cmd_resume(args)
elif args.cmd == "continue":
cmd_continue(args)
except SystemExit:
raise
except CodexUnavailable as exc:
out = getattr(args, "output_dir", "") or ""
bar = "=" * 64
print(f"\n{bar}", flush=True)
print("[ALARM] CODEX 不可用 —— 工作流已停止(不降级)", flush=True)
print(f"原因:{exc}", flush=True)
print("codex(gpt-5.5) 与你其它工作共享额度。请等 codex 空出来后,从断点续跑:", flush=True)
print(f' run_fpga_local.py continue --output-dir "{out}"', flush=True)
print("状态已保存到 SQLite,不会丢失;不会回退到 Claude 或更弱模型。", flush=True)
print(bar, flush=True)
sys.exit(3)
if __name__ == "__main__":
main()
+248
View File
@@ -0,0 +1,248 @@
---
name: fpga-imx6-flow
description: >
Run the fpga-imx6-flow LangGraph workflow at E:\LangGraph_Design\FPGA-imx6-flow —
the closed-loop SMOD (FPGA + i.MX6) hardware-in-the-loop design/debug pipeline:
power on the board, connect JTAG, program bitstream/flash, restart the imx6 app,
analyze (ILA + imx6 serial console), judge against design requirements, and iterate by
modifying RTL/imx6 code. It drives the graph in-process (no langgraph dev) and
hands the 5 interrupts (analyze / meets-requirement / propose / modify-FPGA /
modify-imx6) back to this Claude session to execute.
ALWAYS use this skill whenever the user wants to run, start, drive, resume, or
continue the fpga-imx6-flow workflow / SMOD FPGA+imx6 design loop, or says things
like "启动 fpga-imx6-flow", "跑 imx6 设计流程", "run the imx6 FPGA flow", "执行
SMOD 设计任务", "继续 imx6 工作流", "resume the imx6 design loop", even if they
don't name the project path. Before driving the workflow this skill first confirms
the langgraph conda environment from /langgraph-vivado-install is in place.
---
## What this skill does
Drives the **fpga-imx6-flow** graph (24 nodes / 34 flow-edges, 1:1 with
`Notes/fpga-imx6-flow.excalidraw`). The graph's deterministic hardware/check nodes
(power, JTAG, program, restart) run in-process; its **5 interrupt nodes** pause and
hand control to **this Claude session**, which performs the real work (analysis,
judgment, RTL/app edits) and resumes the same thread. Persistence is a SqliteSaver
file, so `start`/`resume` survive across separate process calls.
| Item | Value |
|---|---|
| Project root | `E:\LangGraph_Design\FPGA-imx6-flow` |
| Conda env | `langgraph` (`C:\Users\bowei\.conda\envs\langgraph`) |
| Graph | `app.main_graph:build_graph` (in-process; no langgraph dev) |
| Driver | `C:\Users\bowei\.claude\skills\fpga-imx6-flow\scripts\run_imx6_flow.py` |
| Checkpoint | `<project>\runs\skill_checkpoint.sqlite` (keyed by `--thread-id`) |
| Design source | `Notes/fpga-imx6-flow.excalidraw`, plan `Notes/Task00.md` |
## ⚠️ 硬件时序铁律(上电 / USB-Blast / 烧写,务必遵守)
**每次给 SMOD 上电之前,USB-Blast 必须先断开;上电完成之后再重新连接。** 绝不在
USB-Blast 处于连接状态时给板卡上电或断电(保护 JTAG 适配器与板卡)。
标准时序(图拓扑已按此实现,手动操作或写 `tools_*` / 真机脚本时也必须遵守):
```
断电(power_off) → 确认已断电 → 断开USB-Blast(blast_off) → 确认已断开
→ 上电(power_on) → 确认已上电 → 连接USB-Blast(blast_on) → 连接JTAG/烧写/抓ILA
```
**烧写 flash 的完整闭环**(固化后必须断电重启从 flash 加载再验证):
```
断电 → 断开USB-Blast → 上电 → 连接USB-Blast → 用 /fpga_flash 烧写flash
→ 断电 → 断开USB-Blast → 上电 → 连接USB-Blast → 通过ILA验证flash内容正确
```
- 在 LangGraph 图里:`power_off → check_power_off →(是) blast_off_a → check_blast_off
→(是) power_on → check_power →(是) blast_on → check_hw`。`flash_burn` 烧写后回到
`power_off` 做整机断电重启,再次走完上述上电时序后才 `analyze`(抓ILA验证)。
- **强化确认**:`power_on` 节点在上电前调用 `_ensure_blast_off_before_power()` 兜底确认
USB-Blast 已断开;若发现仍连接会强制断开并在 `history` 标记 `rule_violation=True`。
- 两个继电器的**串口号不在本工作流里绑定**:唯一来源是 `~/vivado_mcp_usb.conf`(逐机而异,
不入库)。`app/tools_hw.py` 通过 `vivado_mcp.serial_config.load_serial_ports()` 读取它,
串口收发/组帧/CRC 也复用 `vivado_mcp.serial_io`——协议只有一份实现。
文件不存在时调 vivado-mcp 的 **`detect_serial_relays`** 工具自动探测生成。
(对应的 MCP 工具是 `power_on`/`power_off`/`USB_Blast_on`/`USB_Blast_off`,和这里的
`tools_hw` 走同一套底层。)
> **Run via the env python directly** (not bare `conda run`): on this Windows GBK
> console, `conda run` re-prints the child's UTF-8 stdout through a GBK codec and
> **crashes on the Chinese interrupt payloads**. Use the env `python.exe`, or
> `conda run --no-capture-output`. The driver also re-configures its stdout to UTF-8.
```
PY = C:\Users\bowei\.conda\envs\langgraph\python.exe
RUN = C:\Users\bowei\.claude\skills\fpga-imx6-flow\scripts\run_imx6_flow.py
```
## Step 0 — Confirm the environment (REQUIRED before driving)
The workflow runs in the `langgraph` conda env created by **`/langgraph-vivado-install`**.
Verify it before starting, so a missing env surfaces as a clear instruction rather
than a cryptic import error mid-run:
```powershell
$py = "C:\Users\bowei\.conda\envs\langgraph\python.exe"
if (-not (Test-Path $py)) { Write-Host "[MISSING] langgraph env" } else {
& $py -c "import langgraph; from langgraph.checkpoint.sqlite import SqliteSaver; from langgraph.types import Command; print('[OK] langgraph + checkpoint-sqlite')"
}
```
- **If `[OK]`** → proceed to Step 1.
- **If missing / import fails** → tell the user the env isn't ready and to run
**`/langgraph-vivado-install`** first (it creates the `langgraph` env with
`langgraph` + `langgraph-checkpoint-sqlite` and the Vivado link). Do not proceed
until it succeeds.
**For a real-hardware run** (`--real`), additionally confirm the machine's two config
files — **both live in `~`, neither is in the repo** (they hold COM numbers, VM/imx6
passwords, and the Vivado path, all of which differ per machine):
| 文件 | 装什么 | 缺了怎么办 |
|---|---|---|
| `~/vivado_mcp_usb.conf` | 电源继电器 + USB-Blast 继电器的 COM 号 | 调 vivado-mcp 的 **`detect_serial_relays`** 工具自动探测生成 |
| `~/fpga_imx6_flow.json` | imx6 控制台 COM 号 (`ports.imx6`)、`VIVADO_BIN_PATH`、虚拟机/imx6 口令 | 照工程根的 `fpga_imx6_flow.example.json` 手工创建 |
List the machine's ports with `[System.IO.Ports.SerialPort]::GetPortNames()`.
工程根目录下**不该**再有 `env_config.json`——它已废弃,`app/config.py` 见到就报错。
**Default runs are MOCK** (dry-run of the design loop with no hardware), because the
workflow's `tools_*` real-hardware paths are still TODO — see "Mock vs real" below.
## Step 1 — Start the workflow
Ask the user (or infer) the first-pass program mode (`bitstream` default, or `flash`)
and any `--bit-path`/`--flash-path`. Then start:
```powershell
& $py $RUN start --project-dir "E:\LangGraph_Design\FPGA-imx6-flow" `
--thread-id "imx6-<short-task-name>" --program-mode bitstream
# add --real only when hardware tools are wired and the board is connected
```
The driver prints **one JSON line** and exits:
| Exit | `status` | Meaning |
|---|---|---|
| 0 | `done` | Workflow finished (符合设计要求 / 迭代到上限) → Step 3 |
| 1 | `error` | Read `error`, diagnose, report to user |
| 2 | `interrupt` | An interrupt fired → read `interrupt.action` → Step 2 |
## Step 2 — Handle the interrupt, then resume
On exit 2, the JSON's `interrupt.action` tells you what the session must do. Perform
it, then resume the **same `--thread-id`** with a JSON payload. Repeat until exit 0.
| `action` | excalidraw node | What you (the session) do | Resume JSON |
|---|---|---|---|
| `analyze` | 通过ila/串口分析 | Grab imx6 serial log + pull ILA waveform (vivado-mcp); summarize | `{"analysis_log":"<摘要/路径>"}` |
| `meets_requirement` | 符合设计要求? | Judge the analysis against the design requirement | `{"meets": true}` or `{"meets": false}` |
| `propose` | 分析原因,提出修改方案 | Diagnose root cause; decide what to change + next program mode | `{"reason":"...","need_fpga": true/false,"need_imx6": true/false,"program_mode":"bitstream"或"flash"}` |
| `modify_fpga` | 改 RTL→综合→出 bit/flash | Edit RTL, run Vivado synth/impl, produce bit + flash files | `{"bit_path":"...","flash_path":"...","program_mode":"flash"}` |
| `modify_imx6` | VM 改 app→交叉编译 | In the Ubuntu VM (`bowei@192.168.223.128`) edit app + cross-compile | `{"app_path":"..."}` |
Resume command:
```powershell
& $py $RUN resume --project-dir "E:\LangGraph_Design\FPGA-imx6-flow" `
--thread-id "imx6-<short-task-name>" --resume-json '{"meets": false}'
```
Notes:
- Always pass the **same `--thread-id`** used at `start` — that's how the sqlite
checkpoint reattaches to the run (concurrency-safe: different tasks → different
thread ids → isolated state).
- `propose` fans out to BOTH `decide_fpga` and `decide_imx6`; with `need_fpga:false`
and `need_imx6:false` the loop goes straight to `round_done` → re-program. Setting
either true fires the corresponding `modify_*` interrupt next.
- After `round_done` the graph re-asks the program-mode decision; `program_mode` you
set in `propose`/`modify_fpga` chooses bitstream (download bit) vs flash (re-burn +
full power/connect re-sequence). This is the big closed loop.
## Step 3 — Finish
On exit 0 (`status: done`), report to the user: iterations completed and that the
design met requirements (or hit the iteration cap). Offer to inspect
`<project>\runs\` artifacts or the per-step `history`.
To abandon a paused run and start clean, delete `<project>\runs\skill_checkpoint.sqlite`
(or use a fresh `--thread-id`).
## Mock vs real
- **MOCK (default)** — `FPGA_IMX6_MOCK=1`; hardware/check nodes return success
immediately. Use this to dry-run the *design-loop logic* and the 5 interrupts with
no board attached. Great for demonstrating/validating the flow.
- **Real (`--real`)** — drives actual hardware. The `tools_hw` (power + USB-Blast relays,
ports from `~/vivado_mcp_usb.conf`), `tools_imx6` (console restart/kill/log, port from
`~/fpga_imx6_flow.json`'s `ports.imx6`), and `tools_vivado` (JTAG program/flash) real paths are
**now implemented and validated end-to-end (2026-07-05)** on the SMOD_ATSC board. Getting there
took **7 fixes** — see memory `fpga-imx6-flow-tx-safe`: power_on waits `sys init over` before
JTAG (else TX dies); `_PROJ`→SMOD_ATSC; `deploy_app`→no-op (deploy out-of-band, see above);
driver multi-interrupt resume (map by `action`); `power_off` blast-off-first + check retry guard
(else infinite loop); serial open-retry on transient err5 (now in `vivado_mcp.serial_io`);
`program_flash`→proven
`/fpga_flash` flasher + Mfg-ID verify (old tcl was a false-positive). Verify a flash landed with
JTAG `get_hw_ilas` (iter0 = **1 ILA / 0 VIO**; golden = **0 ILA / 2 VIO**).
## imx6 access + file transfer (modify_imx6 / deploy step) — PROVEN 2026-07-05
The `deploy_app` graph node is a no-op (deploy is done **out-of-band by the session**).
Here is the working recipe for the `modify_imx6` interrupt (cross-compile HEAD's app and
push it to the imx6). All of this is **network-based and fast** — no slow serial file push.
**Hosts / creds**
- **imx6 mgmt: `192.168.1.143`** (Windows host is on `192.168.1.125`, same subnet — `ping` works).
The old memory "imx6 not reachable over the network" is **outdated**; the mgmt port is reachable.
Login: **`root` / `anywavecom`**. (The serial console still works too but its login auto-logs-out and
is flaky; prefer network SSH for imx6 shell/log reads.)
- **Cross-compile VM: `bowei@192.168.223.128`** (Ubuntu, password **`lr7402`** — ask the user, don't
guess/brute-force). Toolchain env-setup:
`/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi`.
If VM SSH gives `kex_exchange_identification: Connection closed`, the VM's sshd is down/hung —
ask the user to `sudo systemctl restart sshd`.
**SSH library quirk (critical)**
- The imx6's SSH server is **old (legacy ciphers + `ssh-rsa` host key)**. paramiko **5.0 fails**
(`unknown cipher` / `no acceptable host key`). Install **paramiko 2.12** to a temp dir and use it
for the imx6 (it has TripleDES/aes-cbc/ssh-rsa):
`python -m pip install --target <dir> paramiko==2.12.0` then `PYTHONPATH=<dir> python ... -W ignore`.
The **VM** SSH is modern, so the env's paramiko 5.0 works for the VM (or use 2.12 for both).
- The imx6 SSH has **no SFTP subsystem** (`open_sftp()` → `EOF during negotiation`). Transfer files
via **`cat > file` over an exec channel**:
```python
ch = imx6_client.get_transport().open_session()
ch.exec_command("cat > /aw/anywave/app/imx_atsc_app_head")
with open(local_bin, "rb") as f: ch.sendall(f.read())
ch.shutdown_write(); ch.recv_exit_status() # 0 = ok
```
(The VM has SFTP; `open_sftp().get(...)` works there.)
**Full modify_imx6 flow (validated)**
1. Windows: `git archive --format=tar -o head_atsc.tar HEAD:imx6/src/atsc_app` (13 MB, self-contained).
2. paramiko→VM (pw `lr7402`): SFTP the tar up; extract to a **FRESH dir** (`~/atsc_head_build`, never
touch `atsc_tsoip_build/` etc.); build:
`bash -lc 'source <envsetup>; cd ~/atsc_head_build && make'` → output `imx_atsc_app` (~2.92 MB ELF).
3. paramiko→VM SFTP the built `imx_atsc_app` down to Windows.
4. paramiko-2.12→imx6 (pw `anywavecom`): `cat >`-push it to `/aw/anywave/app/imx_atsc_app_head`,
`chmod 755`, `cp -a imx_atsc_app_head imx_atsc_app` (swap active; golden stays as
`imx_atsc_app.bak_pre_rx1`), md5-verify.
5. Cold-boot (tools_hw blast_off→power_off→power_on, TX-safe) so rc.local runs the new app.
6. Verify: network SSH grep `/var/module_log/imx_atsc_app.log` for `TALISE_radioOn` / `fb init state ok`
/ `fpga[0xBB]=0x03` (sync=1/tvalid=1); spectrum for TX; (ILA needs an `.ltx` → skip unless re-impl).
> The `git HEAD` app (b07d73b) is the known-good **RX1-on-framerA (M=4/lane0x03/RX1RX2) + correct TX**
> build. On-device `imx_atsc_app.rx1` and any `M=2/lane0x04` variant **break TX** — do not use them.
## Troubleshooting
| Symptom | Fix |
|---|---|
| `UnicodeEncodeError: 'gbk' ...` | Don't use bare `conda run`; use the env `python.exe` or `conda run --no-capture-output`. |
| imx6 SSH `unknown cipher` / `no acceptable host key` (paramiko 5) | Use **paramiko 2.12** (temp `--target` dir) for the imx6 legacy SSH; see "imx6 access" above. |
| imx6 `open_sftp` → `EOF during negotiation` | imx6 has no SFTP subsystem; push files via `cat > file` over an exec channel. |
| VM SSH `kex_exchange_identification: Connection closed` | VM sshd down/hung — user `sudo systemctl restart sshd`. |
| `import failed ... Run /langgraph-vivado-install` | The `langgraph` env is missing/incomplete → run `/langgraph-vivado-install`. |
| Exit 1 `NotImplementedError ... tools_*` | A `--real` run hit an unwired hardware tool. Run without `--real` (mock), or implement `tools_*`. |
| `resume` seems to start over | You passed a different `--thread-id` than `start`. Use the same one. |
| Need to see the graph visually | Optional: `langgraph dev` via the project's `langgraph.json` opens Studio — not required to run. |
+144
View File
@@ -0,0 +1,144 @@
"""In-process driver for the fpga-imx6-flow LangGraph workflow.
Builds the compiled graph from the project's app package and drives it with a
persistent SqliteSaver — no `langgraph dev` server. Mirrors the fpga-flow helper
convention: exit code 0 = done, 1 = error, 2 = interrupt fired.
Commands:
start --project-dir <dir> [--program-mode bitstream|flash] [--bit-path ..]
[--flash-path ..] [--thread-id ..] [--real] [--max-iterations N]
resume --project-dir <dir> --resume-json '<json>' [--thread-id ..] [--real]
status --project-dir <dir> [--thread-id ..]
Output: one JSON line on stdout describing status + the pending interrupt (if any).
Run via the env python directly or `conda run --no-capture-output` to avoid the
Windows GBK console re-encode crash on Chinese interrupt payloads.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8") # emit UTF-8 even on a GBK console
except Exception:
pass
DEFAULT_PROJECT = r"E:\LangGraph_Design\FPGA-imx6-flow"
def _emit(obj: dict, code: int) -> None:
print(json.dumps(obj, ensure_ascii=False))
sys.exit(code)
def _pending_interrupt(snap):
for task in snap.tasks:
if task.interrupts:
return task.interrupts[0].value
return None
def main() -> None:
ap = argparse.ArgumentParser(description="Drive the fpga-imx6-flow graph in-process.")
ap.add_argument("command", choices=["start", "resume", "status"])
ap.add_argument("--project-dir", default=DEFAULT_PROJECT)
ap.add_argument("--thread-id", default="imx6-skill-1")
ap.add_argument("--resume-json", default="")
ap.add_argument("--program-mode", default="bitstream", choices=["bitstream", "flash"])
ap.add_argument("--bit-path", default="")
ap.add_argument("--flash-path", default="")
ap.add_argument("--real", action="store_true",
help="drive real hardware (workflow tools_* must be wired; default is MOCK dry-run)")
ap.add_argument("--max-iterations", type=int, default=8)
ap.add_argument("--max-hw-retry", type=int, default=3)
args = ap.parse_args()
project = Path(args.project_dir)
if not (project / "app" / "main_graph.py").exists():
_emit({"status": "error",
"error": f"app/main_graph.py not found under {project}; check --project-dir"}, 1)
# MOCK by default so the design loop can be dry-run with no hardware attached.
os.environ["FPGA_IMX6_MOCK"] = "0" if args.real else "1"
sys.path.insert(0, str(project))
try:
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
from app.main_graph import build_graph
except Exception as exc: # noqa: BLE001
_emit({"status": "error",
"error": f"import failed: {exc}. Run /langgraph-vivado-install to set up the env."}, 1)
runs = project / "runs"
runs.mkdir(exist_ok=True)
db = str(runs / "skill_checkpoint.sqlite")
cfg = {"configurable": {"thread_id": args.thread_id}}
with SqliteSaver.from_conn_string(db) as cp:
graph = build_graph(checkpointer=cp)
try:
if args.command == "start":
init = {
"iteration": 0,
"program_mode": args.program_mode,
"bit_path": args.bit_path,
"flash_path": args.flash_path,
"max_hw_retry": args.max_hw_retry,
"max_iterations": args.max_iterations,
"history": [],
}
graph.invoke(init, cfg)
elif args.command == "resume":
payload = json.loads(args.resume_json) if args.resume_json else {}
snap0 = graph.get_state(cfg)
pend = [intr for task in snap0.tasks for intr in task.interrupts]
if len(pend) <= 1:
graph.invoke(Command(resume=payload), cfg)
else:
# Multiple interrupts pending (parallel fan-out, e.g. modify_fpga +
# modify_imx6). payload must be a map keyed by each interrupt's "action"
# (or interrupt id); resume them all at once with an id->value map.
resume_map = {}
for intr in pend:
val = intr.value if isinstance(intr.value, dict) else {}
action = val.get("action")
if action in payload:
resume_map[intr.id] = payload[action]
elif intr.id in payload:
resume_map[intr.id] = payload[intr.id]
else:
resume_map[intr.id] = {}
graph.invoke(Command(resume=resume_map), cfg)
# status: no invoke, just snapshot
snap = graph.get_state(cfg)
except Exception as exc: # noqa: BLE001
_emit({"status": "error", "error": str(exc)}, 1)
if snap.next:
all_intr = [intr.value for task in snap.tasks for intr in task.interrupts]
_emit({
"status": "interrupt",
"thread_id": args.thread_id,
"mock": not args.real,
"next": list(snap.next),
"iteration": snap.values.get("iteration"),
"interrupt": _pending_interrupt(snap),
"pending_interrupts": all_intr,
"pending_count": len(all_intr),
}, 2)
else:
_emit({
"status": "done",
"thread_id": args.thread_id,
"iteration": snap.values.get("iteration"),
"steps": len(snap.values.get("history", [])),
}, 0)
if __name__ == "__main__":
main()
+10 -3
View File
@@ -118,11 +118,18 @@ python "C:/Users/bowei/.claude/skills/fpga_flash/scripts/program_flash.py" \
- `--mcs-file`:第三步生成的 .mcs 路径
- `--ltx-file`:若设计无 ILA,省略此参数(脚本自动跳过 PROBES.FILE 配置)
此脚本在同一个 Vivado batch 会话中完成:硬件连接 → cfgmem 器件创建 → 擦除/编程/验证。
此脚本在同一个 Vivado batch 会话中完成:硬件连接 → cfgmem 器件创建 → **载入 flash-loader 比特流(关键步骤)** → 擦除/编程/验证 → 回载用户 bit。
**验证成功的标志:** 输出包含 `End of startup status: HIGH` 且无 `ERROR` 行。
**验证成功的标志(真正写入 flash):** 输出必须包含
`Performing Erase Operation... Erase Operation successful`、
`Program/Verify Operation successful`、`Flash programming completed successfully`,
并能读到非零的 `Mfg ID`(如 Winbond=`ef`、Micron=`20`)。
> ⚠️ 仅有 `End of startup status: HIGH` **不代表写了 flash**——那只是把 bit 载入 FPGA
> 易失配置。若看到 `Mfg ID : 0 / Unable to read device properties`,说明 flash-loader
> 没接管 SPI 总线(本 tcl 已用 `create_hw_bitstream`+`program_hw_devices` 载入
> `spi_<part>_pullnone.bit` 修复此问题)。
ILA core 的 `Dropping logic core` WARNING 是正常现象(golden 镜像不含 ILA core),不影响烧写结果。
ILA core 的 `Dropping logic core` WARNING 是正常现象(载入 loader/golden 镜像时不含 ILA core),不影响烧写结果。
---
+15
View File
@@ -38,6 +38,21 @@ set_property PROGRAM.FILES [list $mcs_file] \
[get_property PROGRAM.HW_CFGMEM [lindex [get_hw_devices xc7a200t_0] 0]]
refresh_hw_device [lindex [get_hw_devices xc7a200t_0] 0]
# --- CRITICAL: load Vivado's flash-loader bitstream into the FPGA FIRST so it
# bridges JTAG<->SPI flash and takes over the SPI bus. Without this, the user
# design (or boot image) holds the SPI pins and the flash read returns
# "Mfg ID : 0 / Unable to read device properties" (program fails silently).
# This is exactly what the Vivado GUI "Program Configuration Memory Device"
# does under the hood before program_hw_cfgmem. ---
set loader_bit [get_property PROGRAM.HW_CFGMEM_BITFILE [lindex [get_hw_devices xc7a200t_0] 0]]
create_hw_bitstream -hw_device [lindex [get_hw_devices xc7a200t_0] 0] $loader_bit
program_hw_devices [lindex [get_hw_devices xc7a200t_0] 0]
# Erase + program + verify the SPI flash (this is the actual flash write).
program_hw_cfgmem -hw_cfgmem [get_property PROGRAM.HW_CFGMEM [lindex [get_hw_devices xc7a200t_0] 0]]
# Optionally reload the user bit + probes so the device runs the design (with ILA)
# after flashing. A power-cycle will boot the same content from flash.
if {$has_ltx} {
set_property PROBES.FILE $ltx_file [get_hw_devices xc7a200t_0]
set_property FULL_PROBES.FILE $ltx_file [get_hw_devices xc7a200t_0]
+119
View File
@@ -0,0 +1,119 @@
---
name: fpga_update
description: 通过网络远程升级 SMOD 板(192.168.1.143)上 ATSC 激励器工程的 FPGA 镜像——先用 Vivado + perl 生成 quickboot 的 _update.mcs,再用 TCP 1234 的私有协议把它流式烧进 SPI flash 的 update 槽。只要用户提到"远程升级"、"升级 mcs"、"把新 bit 升上去"、"update mcs"、"远程更新 FPGA"、"Xfpga_ru"、"生成 quickboot mcs"、"quickboot"、"RU_golden 之后怎么升级"、"192.168.1.143 升级",或者在 my_exciter 工程里跑完综合实现后想把结果固化到板子上,就必须用这个 skill——哪怕他们没说"skill"、没提 mcs,只说了"升上去"、"更新一下 FPGA"、"重新出个 mcs"。注意:这条路和网页上的 Remote Update 完全无关(那个只认 imx6 的 tar.gz 固件包)。JTAG 直烧打底镜像请改用 fpga_flash skill。
---
# SMOD FPGA 远程升级
把 `my_exciter` 工程实现出的 `.bit` 变成 quickboot 更新镜像,再通过网络烧进板子的 SPI flash。
板子必须**事先用 JTAG 烧过 `RU_golden`** 才支持远程升级 —— golden 提供上电时的兜底镜像和
IPROG 跳转头。没烧过 golden 就先走 `/fpga_flash`。
## 先搞清楚你在走哪条路
这是最容易出错的地方,两条路互不相干:
| | 这个 skill | 网页 Remote Update |
|---|---|---|
| 升什么 | **FPGA 镜像**(`.mcs`) | **imx6 固件包**(`.tar.gz`) |
| 走哪 | TCP **1234**,私有帧协议 | HTTP `upload.cgi` → `check_package.sh` |
把 `.mcs` 传到网页上不会报错,但 `check_package.sh` 找不到 `*.tar.gz` 就会
`rm -rf /aw/upgrade/*` 把它删掉,什么都不会发生。原厂的 GUI 工具是 `tools/Xfpga_ru.exe`,
没有命令行接口;`scripts/fpga_ru.py` 是它的等价替代,而且会告诉你失败在第几帧。
## 流程
### 1. 生成 `_update.mcs`
```bash
python scripts/gen_update_mcs.py # → bitstream/atsc_exciter_update.mcs
python scripts/gen_update_mcs.py --tag smod_dpd # → bitstream/smod_dpd_update.mcs
```
脚本按顺序跑项目里一直在用的那三个脚本(`post_bitstream.tcl` → `runScript_GenRu.tcl` →
`MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64`),然后校验产物的起始地址是
`0x800000`(这等价于"确实是用 `-imagesize 64` 生成的")。
它还替你挡了一个坑:`runScript_GenRu.tcl` 里是 `glob *.bit`,而 `bitstream/` 目录里躺着二十几个
历史 `.bit`。脚本会把旧的临时挪走、跑完再放回去。**别绕过它去手动跑 tcl**,除非你已经清空了目录。
`-imagesize 64` 不要改。它和 `SpiFlashProgrammer.vhd` 里的 `cAddrUpdateStart/End` 是一对,
理由见 `references/quickboot.md`。
### 2. 确认 ADPC 为 OFF(保险起见)
DPD 的 sample&load 和升级数据流共用同一条 SPI1,而 FPGA 在升级期间会把 SPI1 上任何字都当成镜像数据。
这条路径没被验证过(三次连续升级成功那次 `adpc=0`),所以稳妥起见先关掉:
```bash
# 登录后读 sys_cfg,第 3 个字段就是 adpc(0=OFF)
curl -s -b "session=$SESS" "http://192.168.1.143/atsc_status_cfg.cgi?sys_cfg=&"
```
网页账号是 **`anywavecom` / `anywavecom`**(不是 `root`;`root/anywavecom` 是 SSH 的)。
### 3. 烧进去
```bash
python scripts/fpga_ru.py --mcs bitstream/atsc_exciter_update.mcs
python scripts/fpga_ru.py --mcs ... --dry-run # 只校验文件,不碰硬件
```
先跑 `--dry-run`。它会检查行长、整行切分、EOF 记录、起始地址 —— 这些错误在真机上要等几分钟
才会以一个没有上下文的 `ABORT_ERROR` 暴露出来。
烧写要几分钟(8 MB,约 47600 帧)。**中途不要断电、不要重试。** 真断了也不会变砖:升级一开始就把
切换字擦掉了,冷启动会退回 golden。
成功后 app 会自动重启 imx6、复位 FPGA。然后**冷启动板子**(遵守 SMOD 上电时序)让 FPGA 从 flash
重新加载新镜像。
### 4. 验证
抓 ILA、看 app 日志(`/var/module_log/imx_atsc_app.log`)、或者看频谱。
闭环调试交给 `fpga-imx6-flow` skill。
## 排错
`fpga_ru.py` 会打印失败在哪一步、哪一帧、收到什么 ack。对着这张表看:
- **擦除永不结束**(最容易误判的一个)→ 十有八九 programmer 已经报错退出了。
imx6 的 `POLL_ERASER` **不看 ERROR 位**,把"已失败"当成"还在擦",所以客户端只会一直等。
别信进度条,直接读设备日志:
```bash
ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.1.143 \
"grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3"
```
`rstate=0x7`(ERROR=1, DONE=1, EraseOK=0)= programmer 中止;`rstate=0x0` 才是真的在擦,
擦完会看到 `rstate=0xc` + `UPSTATE_EARSE_END`。
历史上这里的元凶是 `smod_top.v` 没把 flash 的 SPI 引脚接出去,见 `references/protocol.md`。
- **`ABORT_ERROR` 出现在第一帧** → mcs 格式不对,先跑 `--dry-run`。
- **`ABORT_ERROR` 出现在数据流中后段** → 怀疑 SPI1 上有别人(DPD)在写,把 ADPC 关掉重试。
- **没有应答 / 超时** → app 的 `sys_ready` 还是 0,或者 app 没在跑。
`ssh root@192.168.1.143 'ps | grep atsc'`(SSH 密码 `anywavecom`,dropbear 老得很,
命令行 ssh 要加 `-o HostKeyAlgorithms=+ssh-rsa`)。
> 别用 `python ssh.py "tail -f ..."` 之类一次性读到 EOF 的封装去跟日志 —— 它永远不返回,
> 什么也抓不到。升级完再 grep 日志文件即可,`log_printf` 已经落盘。
## 不碰硬件地改协议客户端
`scripts/mock_device.py` 是照着 `tcp_task.c` 的状态机实现的假设备,包括那三个硬限制和
`':'` 重切分行为。改完 `fpga_ru.py` 先拿它跑一遍:
```bash
python scripts/mock_device.py --port 12340 &
python scripts/fpga_ru.py --mcs <某个>_update.mcs --host 127.0.0.1 --port 12340 --yes
```
正确的一次会让 mock 收到**恰好 8388608 字节(8.000 MiB)**—— 正是 `cAddrUpdateEnd - cAddrUpdateStart`。
对不上就是客户端的分帧错了。`--not-ready-every N` / `--abort-at-frame N` 可以逼出重传和中止路径。
## 参考
- `references/protocol.md` —— TCP 1234 帧格式、时序、三个硬限制、DPD 污染缺陷的完整证据链
- `references/quickboot.md` —— flash 布局、切换字兜底、为什么是 `-imagesize 64`、两条烧写路径的区别
+137
View File
@@ -0,0 +1,137 @@
# FPGA 远程升级协议(TCP 1234)
## 首先:这条路和网页升级毫无关系
设备网页 `sys_set.html` 的 **Remote Update** 框是给 **imx6 固件包**用的,不是给 FPGA 用的。
把 `.mcs` 传上去等于扔进垃圾桶:
```
upload.cgi → 存到 /aw/upgrade/ → 通知 app(WEB_UPGRADE)
app: start_upgrade() → set_upgrade_flg(1) → imx_up_thr → check_package.sh
check_package.sh: find /aw/upgrade -name '*.tar.gz' ← 只认 tar.gz
找不到 → ret=3 → rm -rf /aw/upgrade/* ← 你的 mcs 被删了
```
(`start_upgrade()` 还会在 `ctrl==CTRL_LOCAL` 时直接 return 0 —— 本地模式下网页升级本就不生效。)
FPGA 走的是**另一条独立的路**:app 里的 `fpga_upgrade_task()` 在 **TCP 1234** 上起了个 server
(`inc/config.h: UPGRADE_LISTEN_PORT 1234`),跑私有帧协议。原厂用 `tools/Xfpga_ru.exe` 驱动它
(一个 16 KB 的 .NET GUI,没有命令行)。`scripts/fpga_ru.py` 就是它的替代品。
服务端权威实现:`my_exciter/imx6/src/atsc_app/task/tcp_task.c` :: `fpga_upgrade_process()`。
## 帧格式
```
请求 AA | cmd | datalen:u16 小端 | payload | sum | BB
应答 AA | ack | 01 | 00 | ack | sum | BB (固定 7 字节)
sum 从帧头到 sum 之前所有字节的 8-bit 累加和(get_check_sum,见 bsp/general_api.c)
```
服务端校验 `rxdata[0]==0xAA && rxdata[datalen+5]==0xBB`,再比对 `get_check_sum(rxdata, datalen+4)`。
应答里 ack 同时出现在 `[1]` 和 `[4]`;校验和错时 `[1]` 被改写,所以**读 `[4]`**。
| cmd | 值 | ack | 值 |
|---|---|---|---|
| `UPGRADE_START` | 0x01 | `START_OK` | 0x01 |
| `POLL_ERASER` | 0x02 | `ERASER_ING` / `ERASER_END` | 0x02 / 0x03 |
| `W_DATA_BLOCK` | 0x03 | `READY` / `NOT_READY` | 0x04 / 0x05 |
| `W_DATA_BLOCK_LAST` | 0x04 | `ABORT_SUCCESS` / `ABORT_ERROR` | 0x06 / 0x07 |
## 时序
1. `UPGRADE_START`(空负载)→ `START_OK`。
服务端往 FPGA 寄存器 `0xE0` 写 `RESET_EN(bit0)=1` 再写 0,放开 `SpiFlashProgrammer`。
2. 轮询 `POLL_ERASER` 直到 `ERASER_END`。擦 8 MB 要几十秒。
3. 逐帧 `W_DATA_BLOCK`,负载是 **mcs 的原始 ASCII 文本行**。
- `READY` → 发下一帧
- `NOT_READY` → 等 1 ms **重发同一帧**
4. 负载里出现 EOF 记录(`:00000001FF`)后,改发 `W_DATA_BLOCK_LAST`(空负载)轮询,
直到 `ABORT_SUCCESS` 或 `ABORT_ERROR`。服务端最多让你轮 100 次。
5. `ABORT_SUCCESS` 后 app 调 `reset_fpga_system()` 并置 `fpga_up_sys_reboot=1` —— **imx6 会重启,
连接会断**。这是正常的,不是错误。
## 三个会把你坑死的硬限制
都在 `recv_upgrade_data_parse()` / `inc/tcp_task.h` 里,超了就 `ABORT_ERROR`:
1. **负载 ≤ 538 字节**。`rxdata[MAX_DATA_LEN=544]` 装的是**整帧**,帧头尾占 6 字节。
2. **每行长度必须在 `[11, 50]` 字节之间**(`UPDATA_RAW_FRAME_MIN/MAX_LEN`),**含行尾 CRLF**。
实际 mcs 只有三种行长:17(ELA)、45(数据)、13(EOF),都合法。
3. **一帧里必须是整行**。服务端不看换行符,它用 `strchr(payload, ':')` 重新切分,
最后一行按 `strlen` 量到负载末尾。**一行跨两帧 = 两行畸形行 = 直接中止。**
另外服务端一次 `lwip_recv()` 读一帧,所以客户端要一次 `send()` 发完整帧,并关掉 Nagle。
数据行 `:10 AAAA 00 <32 hex> CC` 里只有那 16 个数据字节被取走;地址和 ELA 记录全被忽略
(详见 `references/quickboot.md`)。8 MB 镜像 = 524288 行 × 16 B,按 11 行/帧 → 约 47663 帧。
## 曾经的"第二次远程升级必失败" —— 已定位并修复
**症状**:JTAG 烧完 RU_golden 后第一次远程升级成功;冷启动后再升级第二次,卡在擦除阶段永不结束。
**根因**:`smod_top.v` 里 flash 的三根 SPI 线**根本没接出去**。
```verilog
// 修复前 —— 只是内部 wire,MISO 还被钉死成常量
wire outSpiCsB, outSpiMosi;
wire inSpiMiso = 1'b1;
// RU_golden_top.v 一直是对的
output outSpiCsB ;
output outSpiMosi ;
input inSpiMiso ;
```
`smod_ioplanning.xdc` 和 `timing.xdc` 早就把 `outSpiCsB`(P18) / `outSpiMosi`(R14) / `inSpiMiso`(R15)
约束好了,但顶层没有这几个端口,于是 Vivado 只是丢了三条 CRITICAL WARNING 就继续:
```
CRITICAL WARNING: [Vivado 12-4739] set_output_delay:No valid object(s) found for '[get_ports outSpiCsB]'
```
后果:my_exciter 里的 `SpiFlashProgrammer` 片选和 MOSI 悬空、MISO 恒为 1,读 flash ID 得到全 `0xFF`,
**在擦除之前就报错退出**。golden 的引脚是接好的,所以由 golden 执行的那一次升级永远成功 ——
两次升级执行擦写的是**不同 bitstream 里的 programmer**,这正是"第一次成、第二次败"的由来。
**修复**:把三个信号提升为 `smod_top` 的顶层端口(已改)。实测 `rstate` 从
`0x7`(ERROR=1, DONE=1, EraseOK=0)变为 `0x0` → `0xc`(Ready=1, EraseOK=1),擦除正常结束。
## ⚠ imx6 缺陷:`POLL_ERASER` 不看 ERROR 位
上面那个 FPGA bug 之所以表现为"卡死"而不是"报错",是因为 `tcp_task.c` 的擦除轮询只判断 `EARSE_OK`:
```c
rstate = fpga_byte_read(0x01, FPGA_UPGRADE_CTRL_ADDR);
if (rstate & FPGA_UP_STATUS_EARSE_OK_MASK) ret_data = ERASER_END;
else ret_data = ERASER_ING; // ERROR 位被无视
```
于是"已经失败"和"还在擦"返回同一个 `ERASER_ING`,客户端只能一直等到超时。原厂那个
`Xfpga_ru.exe` 就是这样毫无提示地转圈。
**这个 bug 还在。** 排查时不要信客户端的"erasing...",直接读设备日志:
```bash
ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.1.143 \
"grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3"
```
`rstate=0x7` = programmer 已中止;`rstate=0x0` 才是真的在擦。
**根治**:在 `POLL_ERASER` 分支里加 `if (rstate & FPGA_UPGRADE_STATUS_ERROR_MASK) ret_data = ABORT_ERROR;`。
改完要重新交叉编译并部署 app。
## 尚未证实的风险:DPD 与升级数据流共用 SPI1
这一条**不是**上面那个 bug 的原因,但结构上确实存在,写在这里以免以后踩:
- FPGA 侧升级期间 `inReset_EnableB==0`,`spi_slave32` 常开,SPI1 上任何 32-bit 字都会被当成镜像数据。
`smod_top.v` 自己的注释:`// when doing dpd S&L, MISO is spi read pin`。
- imx6 侧 DPD 的 `ispi_tx_big_block()`(`bsp/dpd_proc.c`)和升级的 `ispi_fpga_send_buf_4Byte()`
**用同一个 `fpga_spi_fd`**(`bsp/isys_config.c`)。
- 互斥量 `g_now_only_upgrade_fpga` 只在 `lcd_task.c` 一处被读,且在 `ERASER_END` 才置位。
另一个标志 `get_upgrade_flg()` 只有网页升级会置位。
实测三次连续升级成功时 `adpc=0`(ADPC_OFF),所以 DPD 的 S&L 没有在跑 —— **这条路径从未被真正验证过**。
稳妥起见,升级前把 ADPC 设为 OFF。
+77
View File
@@ -0,0 +1,77 @@
# Flash 布局与 QuickBoot 兜底
板上 flash:Winbond **w25q128bv**,128 Mbit = 16 MB,SPIx1,**24-bit 地址**,64 KB sector / 4 KB subsector。
## 三个地址,一个都不能记错
来自 `my_exciter.srcs/sources_1/quickboot/SpiFlashProgrammer.vhd`(**RU_golden 和 my_exciter 引用的是同一份文件**,
见 `RU_golden.xpr` 里的 `$PPRDIR/../my_exciter/.../SpiFlashProgrammer.vhd`,所以两个工程的常数天然一致):
```vhdl
cAddrUpdateStart = X"00400000" -- update 镜像起点
cAddrUpdateEnd = X"00C00000" -- update 镜像终点+1 → 槽宽 8 MB
cAddrSwitchWordN25Q = X"00000FFC" -- 切换字
cAddrWidth = 24 -- 和 w25q128bv 匹配
```
flash 实际布局(从 `golden_design_initial.mcs` 解码得到,不是推测):
```
0x000000..0x000FFB FF FF ... 空
0x000FFC 00 99 55 66 切换字(AA 被写成 00 = 无效)
0x001000 20000000 NOOP
30020001 00400000 写 WBSTAR = 0x400000
30008001 0000000F 写 CMD = IPROG → 跳到 update 镜像
0x001050 AA995566 golden bitstream 自己的同步字
..0x3FFFFF golden bitstream(RU_golden_top.bit,3.4 MB)
0x400000..0xBFFFFF update 镜像(8 MB,日常远程升级写这里)
0xC00000..0xFFFFFF 未用
```
**兜底机制**:上电时 FPGA 从地址 0 扫描同步字。
- 切换字 = `AA995566` → 在 0xFFC 同步 → 执行 0x1000 处的 IPROG → 加载 update 镜像。
- 切换字 ≠ `AA995566`(擦除后是 `FFFFFFFF`)→ 0xFFC 不同步 → 继续扫 → 在 0x1050 撞上 golden 的同步字 → **加载 golden**。
远程升级一开始就把切换字所在的 subsector 擦掉(`sProgrammerEraseSwitchWord`,SSE 命令,
擦 `0x000000..0x000FFF`),写完校验通过后再把切换字写回去。所以**升级中途断电只会退回 golden,不会变砖**。
> subsector 是 4 KB,擦 0xFFC 只波及 `0x000000..0x000FFF`。IPROG 头在 `0x001000`,属于下一个
> subsector,**不会被擦到**。golden 是安全的 —— 如果怀疑"升级把 golden 弄坏了",可以排除这条。
## 为什么 `-imagesize 64`
perl 脚本的模型是"golden 和 update 一样大、update 紧跟 golden":
`imageSizeBytes = imagesize(Mbit) × 131072`,`updateStartAddr = imageSizeBytes`。
- **golden**(`MakeSpiFlashProgrammerOnlyInitMcs.pl -imagesize 32`)→ 4 MB → IPROG 里的
WBSTAR = `0x400000`,和 `cAddrUpdateStart` 对上。日志见 `RU_golden/bitstream/gen_initial.log`。
- **update**(`MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64`)→ 8 MB → 负载
`pad(bitstream, 0x7FFFFC) + crc32` 正好 8 MB,和槽宽 `0xC00000-0x400000` 对上。
两个 imagesize 不一样是**故意的**(VHDL 注释里的 "Plan B: golden enlarged to 4MB"),别去"统一"它们。
`_update.mcs` 里的 Intel-HEX 地址写的是 `0x800000`,看起来和 `cAddrUpdateStart=0x400000` 矛盾 ——
**它不矛盾,因为地址被忽略**。imx6 的 `recv_upgrade_data_parse()` 只从 `:10xxxx00<32 hex>` 记录里抠出
16 个数据字节,扩展线性地址记录 `:02000004` 直接当"中间控制字段"丢掉。FPGA 从 `cAddrUpdateStart`
顺序写。所以对远程升级而言,`_update.mcs` 只有**负载长度**和**CRC**是有意义的。
校验 `_update.mcs` 的起始地址是 `0x800000`,本质上是在校验"这个文件是用 `-imagesize 64` 生成的"。
## 两条烧写路径,别搞混
| | JTAG 直烧 | 网络远程升级 |
|---|---|---|
| 文件 | `RU_golden/bitstream/golden_design_initial.mcs` | `bitstream/<tag>_update.mcs` |
| 工具 | `/fpga_flash` skill(flash 型号 `w25q128bv`) | `scripts/fpga_ru.py` |
| 写到 | `0x000000..0x3FFFFF` | `0x400000..0xBFFFFF` |
| 何时用 | 首次装机、板子变砖、换打底镜像 | 日常迭代 |
`_update.mcs` **不能**拿去 JTAG 烧写:它只是 8 MB 裸数据 + CRC,没有 bitstream 头。
反过来 `_initial.mcs` 也不能拿去远程升级。
严守上电时序:断电 → 断 USB-Blast → 上电 → 连 USB-Blast → 烧写 → 断电 → 断 USB-Blast → 上电 → 连 USB-Blast。
## 调试期的第三条路
`program_device` 直接把 `.bit` 下到 FPGA,掉电即失,不碰 flash,配 `.ltx` 还能抓 ILA。
迭代验证用它最快,功能确认后再走远程升级固化。固化用的 bit 要**去掉 ILA 重新实现**。
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Remote-update the SMOD FPGA by streaming a quickboot _update.mcs to the imx6.
This is a Python reimplementation of what ``tools/Xfpga_ru.exe`` (a .NET GUI, no CLI)
does. The device side is ``my_exciter/imx6/src/atsc_app/task/tcp_task.c`` ::
``fpga_upgrade_task``, listening on TCP 1234. It parses the MCS text we send and
pokes the bytes into the FPGA's SpiFlashProgrammer, which erases and writes the
update half of the SPI flash by itself.
This has nothing to do with the web page's "Remote Update" box -- that one runs
check_package.sh, which only accepts *.tar.gz imx6 firmware bundles and deletes
anything else it finds. See references/protocol.md.
Wire format (all values from tcp_task.c / inc/tcp_task.h)::
request AA | cmd | datalen:u16le | payload | sum | BB
response AA | ack | 01 | 00 | ack | sum | BB (7 bytes)
sum 8-bit sum of every byte from the head up to (not incl.) sum
Usage:
python fpga_ru.py --mcs .../bitstream/atsc_exciter_update.mcs
python fpga_ru.py --mcs ... --host 192.168.1.143 --yes
python fpga_ru.py --mcs ... --dry-run # parse + frame, touch no hardware
"""
import argparse
import os
import re
import socket
import sys
import time
# ------------------------------------------------------------------ protocol constants
HEAD, TAIL = 0xAA, 0xBB
UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04
ACK = {
0x01: "START_OK",
0x02: "ERASER_ING",
0x03: "ERASER_END",
0x04: "READY",
0x05: "NOT_READY",
0x06: "ABORT_SUCCESS",
0x07: "ABORT_ERROR",
}
START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03
READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07
# rxdata[] on the device is MAX_DATA_LEN = 544 bytes and holds the *whole* frame,
# so the payload can never exceed 544 - 6 bytes of framing. Going over this silently
# truncates the frame and the tail check fails.
MAX_FRAME = 544
FRAME_OVERHEAD = 6
MAX_PAYLOAD = MAX_FRAME - FRAME_OVERHEAD # 538
# recv_upgrade_data_parse() rejects any line outside this range with -2 -> ABORT_ERROR.
LINE_MIN, LINE_MAX = 11, 50
UPDATE_START_ADDR = 0x800000
ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})")
EOF_RECORD = b":00000001FF"
# ---------------------------------------------------------------------------- framing
def build(cmd, payload=b""):
body = bytes([HEAD, cmd]) + len(payload).to_bytes(2, "little") + payload
return body + bytes([sum(body) & 0xFF, TAIL])
def parse_ack(resp):
if len(resp) != 7 or resp[0] != HEAD or resp[6] != TAIL:
raise ProtocolError(f"malformed response: {resp.hex(' ')}")
return resp[4]
class ProtocolError(RuntimeError):
pass
class Device:
def __init__(self, host, port, timeout):
self.sock = socket.create_connection((host, port), timeout=timeout)
# Every frame is a request/response round trip; Nagle would just add latency.
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def xfer(self, cmd, payload=b"", timeout=None):
if timeout is not None:
self.sock.settimeout(timeout)
self.sock.sendall(build(cmd, payload))
try:
resp = self.sock.recv(16)
except socket.timeout:
raise ProtocolError(
f"no response to cmd 0x{cmd:02X} within {self.sock.gettimeout()}s.\n"
" The app returns an empty reply (and we block) when sys_ready==0 or the\n"
" frame was malformed. Check the app is up: ssh root@<host> 'ps | grep atsc'"
)
if not resp:
raise ProtocolError("device closed the connection")
return parse_ack(resp)
def close(self):
try:
self.sock.close()
except OSError:
pass
# ------------------------------------------------------------------------ mcs handling
def load_chunks(path, max_payload, allow_any_image):
"""Split the MCS into whole-line payloads.
Lines must stay whole: the device re-splits our payload on ':' and measures each
line's length itself, so a line straddling two frames is parsed as two malformed
lines and aborts the upgrade.
"""
with open(path, "rb") as fh:
lines = fh.readlines()
if not lines:
raise SystemExit(f"[FAIL] {path} is empty")
first_ela = None
for line in lines:
m = ELA_RE.match(line)
if m:
first_ela = int(m.group(1), 16) << 16
break
if first_ela != UPDATE_START_ADDR and not allow_any_image:
got = "none" if first_ela is None else f"0x{first_ela:X}"
raise SystemExit(
f"[FAIL] {os.path.basename(path)} starts at {got}, expected 0x{UPDATE_START_ADDR:X}.\n"
" That is not a quickboot _update.mcs. Uploading a base or _initial mcs\n"
" writes garbage into the update slot; the board then falls back to golden\n"
" on the next cold boot. Regenerate with scripts/gen_update_mcs.py.\n"
" (Override with --allow-any-image if you really mean it.)"
)
for n, line in enumerate(lines, 1):
if not line.startswith(b":"):
raise SystemExit(f"[FAIL] line {n} does not start with ':' -- not an Intel-HEX mcs")
if not (LINE_MIN <= len(line) <= LINE_MAX):
raise SystemExit(
f"[FAIL] line {n} is {len(line)} bytes (incl EOL); the device only accepts "
f"{LINE_MIN}..{LINE_MAX} and aborts otherwise"
)
if not lines[-1].startswith(EOF_RECORD):
raise SystemExit(f"[FAIL] last line is {lines[-1]!r}, expected the EOF record {EOF_RECORD.decode()}")
chunks, cur = [], []
size = 0
for line in lines:
if size + len(line) > max_payload and cur:
chunks.append(b"".join(cur))
cur, size = [], 0
cur.append(line)
size += len(line)
if cur:
chunks.append(b"".join(cur))
return chunks, len(lines)
# ------------------------------------------------------------------------------ stages
def do_erase(dev, timeout):
print("[2/4] erasing the update image region (this is the slow part)")
ack = dev.xfer(UPGRADE_START, timeout=30)
if ack != START_OK:
raise ProtocolError(f"UPGRADE_START -> {ACK.get(ack, hex(ack))}, expected START_OK")
deadline = time.monotonic() + timeout
dots = 0
while time.monotonic() < deadline:
ack = dev.xfer(POLL_ERASER, timeout=30)
if ack == ERASER_END:
print("\n erase complete")
return
if ack != ERASER_ING:
raise ProtocolError(f"POLL_ERASER -> {ACK.get(ack, hex(ack))}")
dots += 1
print(f"\r erasing... {dots * 0.5:.0f}s", end="", flush=True)
time.sleep(0.5)
raise ProtocolError(
f"flash still erasing after {timeout}s.\n"
" POLL_ERASER only ever reports ERASER_ING/ERASER_END -- the app never checks the\n"
" ERROR bit, so a programmer that aborted looks identical to one that is still busy.\n"
" Read the real status on the device:\n"
" ssh root@<host> \"grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3\"\n"
" rstate=0x7 (ERROR|DONE, EraseOK=0) means SpiFlashProgrammer aborted immediately --\n"
" the running design cannot reach the flash. See references/protocol.md.")
def do_write(dev, chunks, total_lines, retries):
print(f"[3/4] streaming {total_lines} mcs lines in {len(chunks)} frames")
sent_lines = 0
t0 = time.monotonic()
for i, chunk in enumerate(chunks):
for attempt in range(retries + 1):
ack = dev.xfer(W_DATA_BLOCK, chunk, timeout=60)
if ack == READY:
break
if ack == NOT_READY:
# Device asks us to re-send this exact block after a short pause.
time.sleep(0.001)
continue
if ack in (ABORT_SUCCESS, ABORT_ERROR):
return ack
raise ProtocolError(f"W_DATA_BLOCK -> {ACK.get(ack, hex(ack))}")
else:
raise ProtocolError(f"frame {i} still NOT_READY after {retries} retries")
sent_lines += chunk.count(b"\n")
if i % 2000 == 0 or i == len(chunks) - 1:
pct = 100.0 * sent_lines / total_lines
rate = sent_lines / max(time.monotonic() - t0, 1e-6)
eta = (total_lines - sent_lines) / max(rate, 1e-6)
print(f"\r {pct:5.1f}% {sent_lines}/{total_lines} lines ETA {eta/60:.1f} min",
end="", flush=True)
print()
return None
def do_finish(dev, timeout):
print("[4/4] waiting for the FPGA to report the write is done")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
ack = dev.xfer(W_DATA_BLOCK_LAST, timeout=30)
except (ProtocolError, ConnectionResetError, OSError) as exc:
# On success the app calls reset_fpga_system() and reboots the imx6, so the
# socket dying right here is expected -- but we can no longer read the ack.
raise ProtocolError(f"link dropped while waiting for the final ack: {exc}")
if ack in (ABORT_SUCCESS, ABORT_ERROR):
return ack
if ack != READY:
raise ProtocolError(f"W_DATA_BLOCK_LAST -> {ACK.get(ack, hex(ack))}")
time.sleep(0.2)
raise ProtocolError(f"no final verdict after {timeout}s")
# -------------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--mcs", required=True, help="path to the quickboot <tag>_update.mcs")
ap.add_argument("--host", default="192.168.1.143")
ap.add_argument("--port", type=int, default=1234)
ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
ap.add_argument("--dry-run", action="store_true", help="validate and frame the mcs, contact nothing")
ap.add_argument("--allow-any-image", action="store_true",
help="skip the 0x800000 start-address check (you almost never want this)")
ap.add_argument("--max-payload", type=int, default=MAX_PAYLOAD)
ap.add_argument("--erase-timeout", type=float, default=600)
ap.add_argument("--finish-timeout", type=float, default=120)
ap.add_argument("--retries", type=int, default=200)
args = ap.parse_args()
if not 0 < args.max_payload <= MAX_PAYLOAD:
raise SystemExit(f"[FAIL] --max-payload must be 1..{MAX_PAYLOAD} (device rxdata is {MAX_FRAME} bytes)")
print(f"[1/4] validating {args.mcs}")
chunks, total_lines = load_chunks(args.mcs, args.max_payload, args.allow_any_image)
mb = os.path.getsize(args.mcs) / 1e6
print(f" {mb:.1f} MB, {total_lines} lines, {len(chunks)} frames, starts at 0x{UPDATE_START_ADDR:X}")
if args.dry_run:
print("[dry-run] mcs is well-formed and would stream cleanly. Nothing was sent.")
return
if not args.yes:
print(f"\nAbout to erase and rewrite the update image on {args.host}.")
print("The golden image is untouched, so a failure here is recoverable by cold boot.")
print("Do NOT power the board off while this runs.")
print("\nMake sure DPD is OFF. The exciter's DPD sample&load writes to the same SPI1 bus\n"
"we stream the image over, and the FPGA's spi_slave32 will happily fold those words\n"
"into the flash image. See references/protocol.md.")
if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
raise SystemExit("aborted")
dev = Device(args.host, args.port, timeout=30)
try:
do_erase(dev, args.erase_timeout)
verdict = do_write(dev, chunks, total_lines, args.retries)
if verdict is None:
verdict = do_finish(dev, args.finish_timeout)
finally:
dev.close()
if verdict == ABORT_SUCCESS:
print("\n[OK] FPGA reports the update image was written and verified.")
print("The app has reset the FPGA and is rebooting the imx6.")
print("Now cold-boot the board (observe the SMOD power-up sequence) to load the new image.")
return
raise SystemExit(
f"\n[FAIL] device returned {ACK.get(verdict, hex(verdict))}.\n"
"The update slot is now half-written, but the switch word was erased at the start,\n"
"so the board still cold-boots golden. Nothing is bricked.\n\n"
"If this was ABORT_ERROR late in the stream, suspect SPI1 contention: the exciter's\n"
"DPD sample&load shares fpga_spi_fd with this upload, and the FPGA folds any SPI1 word\n"
"into the image. Turn DPD off and retry. references/protocol.md has the full story.")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Build the quickboot _update.mcs for the SMOD ATSC exciter.
Runs exactly the three scripts the project has always used, in order:
1. tcl_1/post_bitstream.tcl impl_1/*.bit,.bin,.ltx -> bitstream/
2. tcl_1/runScript_GenRu.tcl write_cfgmem -size 64 -interface SPIx1
-> bitstream/atsc_exciter.mcs
3. tcl_1/MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 atsc_exciter.mcs
-> bitstream/atsc_exciter_update.mcs
Step 2 does ``glob *.bit`` inside bitstream/, so every stale .bit sitting there gets
concatenated into one giant image. bitstream/ accumulates iteration bits, so this
script moves the old ones aside for the duration and puts them back afterwards.
The resulting _update.mcs is ONLY for the network remote update (scripts/fpga_ru.py).
It cannot be JTAG-flashed -- see references/quickboot.md.
"""
import argparse
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile
REPO = r"E:\Xilinx_design\smod\SMOD_ATSC\my_exciter"
TCL_DIR = os.path.join(REPO, "my_exciter.srcs", "tcl_1")
BITSTREAM = os.path.join(REPO, "bitstream")
IMPL = os.path.join(REPO, "my_exciter.runs", "impl_1")
POST_BITSTREAM = os.path.join(TCL_DIR, "post_bitstream.tcl")
GEN_RU = os.path.join(TCL_DIR, "runScript_GenRu.tcl")
PERL_SCRIPT = os.path.join(TCL_DIR, "MakeSpiFlashProgrammerMcsFiles_backup.pl")
DEFAULT_VIVADO = r"D:\Xilinx\Vivado\2018.1\bin\vivado.bat"
# runScript_GenRu.tcl hardcodes this name.
BASE_MCS = "atsc_exciter.mcs"
UPDATE_MCS = "atsc_exciter_update.mcs"
# The perl script emits these alongside the one we want.
BYPRODUCTS = ("atsc_exciter_initial.mcs", "atsc_exciter_golden.mcs", "atsc_exciter_total.mcs",
"atsc_exciter_update2.mcs", "atsc_exciter_simple.mcs")
# ":02000004HHHH" is the Intel-HEX extended-linear-address record.
ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})")
EXPECT_ELA = 0x800000
def sh(cmd, cwd=None):
print(f" $ {' '.join(cmd)}", flush=True)
if subprocess.run(cmd, cwd=cwd).returncode != 0:
sys.exit("[FAIL] command failed; see output above")
def first_ela(path):
with open(path, "rb") as fh:
for line in fh:
m = ELA_RE.match(line)
if m:
return int(m.group(1), 16) << 16
return None
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--vivado", default=DEFAULT_VIVADO)
ap.add_argument("--tag", help="rename the result to <tag>_update.mcs so it survives the next run")
ap.add_argument("--keep-byproducts", action="store_true")
args = ap.parse_args()
for path, what in ((args.vivado, "vivado"), (POST_BITSTREAM, "post_bitstream.tcl"),
(GEN_RU, "runScript_GenRu.tcl"), (PERL_SCRIPT, "perl script")):
if not os.path.exists(path):
sys.exit(f"[FAIL] {what} not found: {path}")
impl_bits = glob.glob(os.path.join(IMPL, "*.bit"))
if len(impl_bits) != 1:
sys.exit(f"[FAIL] expected exactly one .bit in {IMPL}, found {len(impl_bits)}: "
f"{[os.path.basename(b) for b in impl_bits]}")
print(f"[1/4] implementation bit: {os.path.basename(impl_bits[0])}")
# Park every stale .bit so runScript_GenRu.tcl's `glob *.bit` can only see the fresh one.
stash = tempfile.mkdtemp(prefix="smod_bit_stash_")
parked = glob.glob(os.path.join(BITSTREAM, "*.bit"))
for b in parked:
shutil.move(b, os.path.join(stash, os.path.basename(b)))
if parked:
print(f"[2/4] parked {len(parked)} stale .bit files from bitstream/ (restored at the end)")
try:
# One vivado launch, both tcl scripts: each re-derives its own path from [info script].
wrapper = os.path.join(stash, "run_both.tcl")
with open(wrapper, "w") as fh:
fh.write(f'source {{{POST_BITSTREAM}}}\n')
fh.write(f'source {{{GEN_RU}}}\n')
print(f"[3/4] vivado: post_bitstream.tcl + runScript_GenRu.tcl -> {BASE_MCS}")
sh([args.vivado, "-mode", "batch", "-nojournal", "-notrace", "-source", wrapper], cwd=stash)
base = os.path.join(BITSTREAM, BASE_MCS)
if not os.path.exists(base):
sys.exit(f"[FAIL] {base} was not produced")
print(f"[4/4] perl MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 {BASE_MCS}")
sh(["perl", PERL_SCRIPT, "-imagesize", "64", BASE_MCS], cwd=BITSTREAM)
finally:
for name in os.listdir(stash):
if not name.endswith(".bit"):
continue
dest = os.path.join(BITSTREAM, name)
# post_bitstream.tcl just copied the fresh impl_1 bit here under this same
# name; putting the parked (stale) one back would silently undo that.
if not os.path.exists(dest):
shutil.move(os.path.join(stash, name), dest)
shutil.rmtree(stash, ignore_errors=True)
update = os.path.join(BITSTREAM, UPDATE_MCS)
if not os.path.exists(update):
sys.exit(f"[FAIL] {UPDATE_MCS} was not produced")
# -imagesize 64 must place the image at 0x800000. If it doesn't, the payload is not
# 8 MB and the FPGA's CRC check over 0x400000..0xC00000 will fail after a long upload.
ela = first_ela(update)
if ela != EXPECT_ELA:
got = "none" if ela is None else f"0x{ela:X}"
sys.exit(f"[FAIL] {UPDATE_MCS} starts at {got}, expected 0x{EXPECT_ELA:X}. Do not upload it.")
if not args.keep_byproducts:
for name in BYPRODUCTS:
p = os.path.join(BITSTREAM, name)
if os.path.exists(p):
os.remove(p)
if args.tag:
tagged = os.path.join(BITSTREAM, f"{args.tag}_update.mcs")
shutil.move(update, tagged)
update = tagged
print(f"\n[OK] {update} ({os.path.getsize(update)/1e6:.1f} MB)")
print(f" base mcs kept at {os.path.join(BITSTREAM, BASE_MCS)}")
print("\nNext: python scripts/fpga_ru.py --mcs \"%s\"" % update)
print(" Turn DPD off first -- see references/protocol.md.")
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""A fake SMOD imx6 that speaks the FPGA remote-update protocol on TCP 1234.
Mirrors ``fpga_upgrade_process()`` in tcp_task.c as literally as possible, including
the parts that bite: the ':'-based re-splitting of our payload, the 11..50 byte line
limit, and the READY-then-ABORT_SUCCESS handshake at the end.
The point is to be able to exercise fpga_ru.py end-to-end without erasing a real
flash. If a change to fpga_ru.py passes against this and against the real board,
the two agree; if it passes here but not there, this file is the thing that's wrong.
python mock_device.py --port 12340
python fpga_ru.py --mcs some_update.mcs --host 127.0.0.1 --port 12340 --yes
Fault injection, to prove the client's retry/abort paths actually work:
--not-ready-every N answer NOT_READY to every Nth data frame
--erase-polls N answer ERASER_ING to the first N POLL_ERASER frames
--abort-at-frame N answer ABORT_ERROR on the Nth data frame
"""
import argparse
import socket
import sys
HEAD, TAIL = 0xAA, 0xBB
UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04
START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03
READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07
LINE_MIN, LINE_MAX = 11, 50
MAX_DATA_LEN = 544
def reply(ack):
tx = bytes([HEAD, ack, 1, 0, ack])
return tx + bytes([sum(tx) & 0xFF, TAIL])
def split_lines_like_c(payload):
"""Reproduce the strchr(':') walk in W_DATA_BLOCK.
The device never looks at newlines: it finds the first ':', then each following
':' ends the previous line. The final line runs to the NUL terminator, which is
why a payload that ends mid-line silently mis-measures that line.
"""
out = []
start = payload.find(b":")
if start < 0:
return None
while True:
nxt = payload.find(b":", start + 1)
out.append(payload[start:] if nxt < 0 else payload[start:nxt])
if nxt < 0:
return out
start = nxt
def parse_line(line):
"""recv_upgrade_data_parse(): -2 bad length, -1 EOF record, 16 data bytes, 0 control."""
if not (LINE_MIN <= len(line) <= LINE_MAX):
return -2
length = int(line[1:3], 16)
rectype = int(line[7:9], 16)
if length == 0x10 and rectype == 0x00:
return 16
if length == 0x00 and rectype == 0x01:
return -1
return 0
class Mock:
def __init__(self, args):
self.args = args
self.bytes_written = 0
self.data_frames = 0
self.erase_polls = 0
self.file_end = False
self.done_polls = 0
def on_frame(self, cmd, payload):
if cmd == UPGRADE_START:
self.bytes_written = 0
self.erase_polls = 0
self.file_end = False
return START_OK
if cmd == POLL_ERASER:
self.erase_polls += 1
return ERASER_ING if self.erase_polls <= self.args.erase_polls else ERASER_END
if cmd == W_DATA_BLOCK:
self.data_frames += 1
if self.args.abort_at_frame and self.data_frames == self.args.abort_at_frame:
return ABORT_ERROR
if self.args.not_ready_every and self.data_frames % self.args.not_ready_every == 0:
return NOT_READY # client must re-send this exact frame
lines = split_lines_like_c(payload)
if lines is None:
return ABORT_ERROR
ack = READY
for line in lines:
n = parse_line(line)
if n == -2:
print(f" [mock] ABORT: bad line ({len(line)} bytes) {line[:20]!r}", file=sys.stderr)
return ABORT_ERROR
if n == -1:
self.file_end = True
ack = READY # check_fpga_upgrade_done() is not DONE yet
elif n > 0:
self.bytes_written += n
return ack
if cmd == W_DATA_BLOCK_LAST:
self.done_polls += 1
if self.done_polls < self.args.done_polls:
return READY
return ABORT_SUCCESS if self.file_end else ABORT_ERROR
return ABORT_ERROR
def serve_one(self, conn):
buf = b""
while True:
data = conn.recv(MAX_DATA_LEN)
if not data:
return
buf += data
# The real device does a single recv() per frame; we tolerate splits so a
# client bug shows up as a protocol error rather than a hang.
while len(buf) >= 6:
datalen = int.from_bytes(buf[2:4], "little")
total = datalen + 6
if len(buf) < total:
break
frame, buf = buf[:total], buf[total:]
if frame[0] != HEAD or frame[-1] != TAIL:
print(f" [mock] bad framing {frame[:4].hex(' ')}", file=sys.stderr)
return
if sum(frame[:datalen + 4]) & 0xFF != frame[datalen + 4]:
conn.sendall(reply(NOT_READY))
continue
conn.sendall(reply(self.on_frame(frame[1], frame[4:4 + datalen])))
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=12340)
ap.add_argument("--erase-polls", type=int, default=2)
ap.add_argument("--done-polls", type=int, default=1)
ap.add_argument("--not-ready-every", type=int, default=0)
ap.add_argument("--abort-at-frame", type=int, default=0)
args = ap.parse_args()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", args.port))
srv.listen(1)
print(f"[mock] listening on 127.0.0.1:{args.port}", flush=True)
conn, _ = srv.accept()
mock = Mock(args)
try:
mock.serve_one(conn)
finally:
conn.close()
mib = mock.bytes_written / (1 << 20)
print(f"[mock] {mock.data_frames} data frames, {mock.bytes_written} bytes ({mib:.3f} MiB), "
f"file_end={mock.file_end}")
# A correct run streams exactly the 8 MiB update image.
sys.exit(0 if mock.file_end else 1)
if __name__ == "__main__":
main()
+172
View File
@@ -0,0 +1,172 @@
---
name: langgraph-plan-review
description: Use the LangGraph plan-review workflow from the current Claude Code session. Trigger when the user asks to create Taskxx plans, run the Claude/Codex review loop, generate or revise Notes/Taskxx.md, or use the LangGraph workflow described by Task00.md. This skill uses the current Claude Code environment for Claude steps and only invokes the Python LangGraph app for Codex review and discuss file numbering.
---
# LangGraph Plan Review Workflow
Use this skill to run the LangGraph plan-review workflow. All scripts are bundled under the skill's own `scripts\` directory (`C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\`) — no dependency on `E:\LangGraph_Design\lab00`.
## ⚠️ 强制执行规则(MANDATORY — read first)
**plan-review 工作流必须、且只能通过 `langgraph dev` 服务器来驱动。**
- ✅ **唯一允许的入口**:`run_langgraph.ps1` → `run_workflow.py`。后者是一个**纯 REST/SDK 客户端**
(`langgraph_sdk.get_sync_client(url=http://127.0.0.1:2024)`),每次 start/resume 都把 run
投递到 `langgraph dev` 服务器执行。先用 `manage_dev_server.ps1 start` 把服务器拉起。
这样 LangGraph Studio 才能实时追踪线程、节点、中断与状态。
- 🚫 **绝对禁止**:直接 `import` / 在进程内运行本 skill 的图定义模块
(`scripts\graph.py`、`scripts\workflow.py`、`scripts\agents.py`)来跑工作流。包括但不限于:
- `python -c "from graph import ...; graph.invoke(...)"` 或 `.stream(...)`
- 写临时脚本 build 编译图后在进程内调用
- 任何绕过 `http://127.0.0.1:2024` 的「就地执行」方式
这类调用在 dev server 进程**之外**运行,Studio 追踪不到状态,**一律不允许**——
即使只是想「快速验证一个节点」也不行。
- ℹ️ **例外说明**:`review_task.py`(Codex 评审 shim)会直接调 `codex`,这是**合规的**——
它是由运行在 dev server 内的图通过 `codex_review_command` 调起的评审步骤,不是绕过
dev server 去驱动工作流本身。区别在于:驱动图 = 必须走 dev server;图内部调用的工具 = 正常。
- 🛠 **改了 `graph.py`/`workflow.py`/`agents.py` 后**:dev server 内存里仍是旧代码。先
`manage_dev_server.ps1 stop` 再 `start`(或让 `--reload` 生效),再经 `run_langgraph.ps1`
重跑,使改动在被追踪的运行中生效。
## Pre-flight: Environment Setup
Before running the workflow for the first time (or when unsure the environment is ready), verify that the required packages are installed in the `langgraph` conda env:
```powershell
# Install langgraph dev server, SDK, and HTTP client
conda run -n langgraph pip install "langgraph-cli[inmem]" langgraph-sdk httpx
# Verify the SDK is importable
conda run -n langgraph python -c "from langgraph_sdk import get_sync_client; print('SDK OK')"
# Verify the langgraph dev command is available
conda run -n langgraph langgraph --version
```
| Package | Purpose |
|---|---|
| `langgraph-cli[inmem]` | Provides the `langgraph dev` server command |
| `langgraph-sdk` | Python client used by `run_workflow.py` to submit runs |
| `httpx` | HTTP health-check in `run_workflow.py` |
**Codex auth** (if not already set up): `C:\Users\bowei\.codex\auth.json` must contain a valid ChatGPT OAuth token. Run `codex auth login` if the file is missing.
## Invariants
- **Drive the graph only through the dev server** (see the mandatory rule above): `run_langgraph.ps1` → `run_workflow.py` → `http://127.0.0.1:2024`. Never import or run `graph.py`/`workflow.py`/`agents.py` in-process to execute the workflow — that bypasses the dev server and LangGraph Studio cannot track it.
- Use the current Claude Code session for all Claude Code work. Do not launch another `claude` process.
- Assume the current Claude Code session already has `--dangerously-skip-permissions`.
- Run Python through the recorded conda environment. `run_langgraph.ps1` resolves conda from `-CondaExe`, `CONDA_EXE`, `conda` on PATH, or the user-profile miniconda fallback:
```powershell
<resolved conda.exe> run -n langgraph python
```
- Codex CLI is installed in the `langgraph` conda env as `codex.cmd` at `C:\Users\bowei\.conda\envs\langgraph\codex.cmd`. It must be invoked via `cmd /c` because `.cmd` files are not directly executable by Python subprocess on Windows.
- Codex auth uses ChatGPT OAuth tokens stored in `C:\Users\bowei\.codex\auth.json`. No API key is needed.
- `run_langgraph.ps1` sets `CODEX_HOME` from `-CodexHome`, existing `CODEX_HOME`, or `<UserProfile>\.codex`.
- **Always use `review_task.py` as the Codex review shim.** Pass it via `--codex-review-command` as a JSON array with no spaces. This shim handles all Windows-specific codex invocation requirements: `cmd /c`, stdin prompt passing, `--skip-git-repo-check`, session resume, and explicit `cwd`.
- Keep all workflow files under `<Claude launch work_dir>\Notes` unless the user explicitly overrides `-NotesDir`.
- `TaskId` is the numeric suffix, such as `01`; write the plan to `Notes\Task01.md`.
- `discussxx-yy.md` numbering is managed by the LangGraph app for Codex review files. If Claude rejects a review comment, create the next numbered discuss file manually before rerunning review.
- The workflow uses LangGraph `interrupt()` and persists state via the langgraph dev server.
- Each `run_langgraph.ps1` invocation advances the graph to the next interrupt or completion. Use `-Resume` to continue the same thread after completing the requested Claude Code action.
## Dev Server Lifecycle
The `langgraph dev` server must be running for the entire duration of a workflow session.
Use `manage_dev_server.ps1` to start it before the first `run_langgraph.ps1` call and stop it after the workflow completes.
```powershell
# Start (idempotent — safe to call even if already running)
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
# Stop (call once when workflow returns {"status":"done"})
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
# Status check
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 status
```
- `start` waits up to 45 s for `http://127.0.0.1:2024/ok` to respond, then prints the Studio URL.
- `stop` kills the process by PID file and removes it; falls back to killing by port 2024 if the PID is stale.
- If the server is already running, `start` is a no-op.
- Server stdout/stderr logs are written to `%TEMP%\langgraph_dev_server.stdout.log` and `%TEMP%\langgraph_dev_server.stderr.log` for debugging.
- **Studio URL**: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024` — open in browser to track workflow execution in real time.
## Workflow
1. Determine `TaskId` from the user. If missing, ask for it.
2. Start the dev server (idempotent):
```powershell
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
```
Wait for the "[dev-server] Ready" line in the output. Then **immediately tell the user** in your text response:
- that the dev server is running, and
- the Studio URL: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024`
Example message to user:
> LangGraph dev server 已就绪。可在浏览器中打开 Studio 实时监控工作流:
> https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
3. Start or resume the LangGraph app:
```powershell
# Use double backslashes (\\) in the JSON string so Windows paths are valid JSON.
# $task_path / $latest_discuss_path / etc. are Python Template placeholders — keep them literal.
$codexCmd = '["C:\\Users\\bowei\\.conda\\envs\\langgraph\\python.exe","C:\\Users\\bowei\\.claude\\skills\\langgraph-plan-review\\scripts\\review_task.py","$task_path","$latest_discuss_path","$codex_session_id","$work_dir"]'
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\run_langgraph.ps1 `
-TaskId <TaskId> `
-Prompt "<original user request>" `
-CodexReviewCommand $codexCmd `
-Timeout 300
```
4. Inspect the returned JSON. If `status` is `waiting`, perform `action_required` in the current Claude Code session:
- `write_plan`: write `Task<TaskId>.md` with project background, task objective, and concrete implementation plan, then resume with `-Resume 'true'`.
- `decide`: read the discuss file and resume with `-Resume '{"accepts":true}'` or `-Resume '{"accepts":false,"reason":"..."}'`.
- `revise`: revise `Task<TaskId>.md`, then resume with `-Resume 'true'`.
- `write_rejection`: write the specified `output_path`, then resume with `-Resume 'true'`.
**Why `review_task.py`**: Using the shim keeps all codex-specific logic in Python, and the JSON array stays simple.
`run_langgraph.ps1` base64-encodes the JSON command and resume payload before passing them through `conda run`, so normal PowerShell single-quoted JSON can be used. **Windows paths inside the JSON string must use `\\` (double backslash) to produce valid JSON**, e.g. `C:\\Users\\bowei\\...`. `run_langgraph.ps1` automatically sets `PYTHONIOENCODING=utf-8` so prompts containing Unicode (Chinese, etc.) pass through correctly.
5. Inspect the command output after each resume.
- Check `"status"` to determine next action: `"waiting"` means perform the next `action_required`; `"done"` means the workflow is complete.
- If a new `latest_discuss_path` is present in the output, read that discuss file for context.
6. Stop when the workflow returns `{"status":"done", ...}`:
```powershell
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
```
## Rejection File Rule
When the `action_required` is `write_rejection`, the interrupt payload already contains the exact file path:
```json
{"action": "write_rejection", "output_path": "Notes/discuss04-01.md", "reason_hint": "..."}
```
Write the rejection content directly to `action_required.output_path`:
```markdown
# discuss<TaskId>-<yy>
## Claude Code Rejection
<reason>
```
## Notes
- The LangGraph app automatically scans existing discuss files and writes the next Codex review file without overwriting old discussions.
- The app no longer accepts Claude command arguments; Claude actions happen in this current session.
- If `codex` is unavailable, report that the review step cannot run rather than simulating it.
- `review_task.py` passes the review prompt through stdin with `-` to avoid Windows command-line quoting issues with multiline prompts.
- `review_task.py` passes `--skip-git-repo-check` so the workflow can run from Claude launch directories that are not git repositories.
- `review_task.py` reads Codex final output via `--output-last-message` so intermediate JSONL progress messages are not treated as review findings.
- `review_task.py` persists the Codex session id through LangGraph state and uses `codex exec resume` on later review passes.
- `review_task.py` runs Codex with the original Claude Code launch working directory as cwd.
- Codex review output is normalized to JSON for `agents.py`: `{"has_serious_issue":true/false,"comment":"...","codex_session_id":"..."}`.
- If the dev server restarts, `run_workflow.py` detects that the stored thread no longer exists and creates a new thread automatically.
- `run_workflow.py` passes the codex command and timeout as `config["configurable"]` per run — no server restart needed when switching projects.
+9
View File
@@ -0,0 +1,9 @@
{
"dependencies": [
"langgraph",
"langgraph-checkpoint-sqlite"
],
"graphs": {
"plan_review": "./scripts/graph.py:graph"
}
}
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import logging
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from string import Template
from typing import Mapping, Sequence
@dataclass(frozen=True)
class ReviewResult:
has_serious_issue: bool
comment: str = ""
codex_session_id: str = ""
def _format_args(command: Sequence[str], values: Mapping[str, str]) -> list[str]:
return [Template(part).safe_substitute(values) for part in command]
def _run_command(command: Sequence[str], values: Mapping[str, str], timeout: int) -> str:
args = _format_args(command, values)
logging.info("Running: %s", args[0] if args else "")
t0 = time.monotonic()
result = subprocess.run(
args,
capture_output=True,
check=False,
text=True,
timeout=timeout,
)
elapsed = time.monotonic() - t0
logging.info("Finished in %.1fs, exit=%d", elapsed, result.returncode)
if result.returncode != 0:
raise RuntimeError(
f"Command failed with exit code {result.returncode}: {args}\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
return result.stdout.strip()
class AgentRunner:
"""Boundary around Codex CLI calls.
The Claude Code steps run in the current Claude Code session through the
skill instructions. This runner only invokes Codex review commands.
Commands are injected by passing argv lists with Template placeholders.
Supported placeholders: task_id, user_prompt, work_dir, task_path,
latest_discuss_path, discuss_path, notes_dir, codex_session_id.
"""
def __init__(
self,
*,
codex_review_command: Sequence[str],
timeout: int = 600,
) -> None:
self.codex_review_command = codex_review_command
self.timeout = timeout
def review(self, values: Mapping[str, str]) -> ReviewResult:
enriched = {**values, "timeout": str(self.timeout)}
output = _run_command(self.codex_review_command, enriched, self.timeout)
if not output:
return ReviewResult(has_serious_issue=False)
try:
payload = json.loads(output)
except json.JSONDecodeError:
return ReviewResult(has_serious_issue=True, comment=output)
return ReviewResult(
has_serious_issue=bool(payload.get("has_serious_issue")),
comment=str(payload.get("comment", "")).strip(),
codex_session_id=str(payload.get("codex_session_id", "")).strip(),
)
+19
View File
@@ -0,0 +1,19 @@
"""Module-level graph export for `langgraph dev`.
The Codex review command is injected per-run via config["configurable"],
so no runner is needed at build time. The server loads this module once;
every submitted run brings its own command through the LangGraph SDK client.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure sibling modules (agents, workflow) are importable when langgraph dev
# loads this file from the skill root via ./scripts/graph.py:graph.
sys.path.insert(0, str(Path(__file__).parent))
from workflow import build_workflow # noqa: E402
# langgraph dev manages its own persistence — do not pass a checkpointer here.
graph = build_workflow()
@@ -0,0 +1,197 @@
<#
.SYNOPSIS
Manage the langgraph dev server on Windows.
.DESCRIPTION
Start, stop, or check the status of the langgraph dev server.
The server starts from the skill root (where langgraph.json lives) so the
graph module is discovered correctly.
.PARAMETER Command
start | stop | status
.EXAMPLE
manage_dev_server.ps1 start # idempotent — safe to call if already running
manage_dev_server.ps1 stop # call once workflow returns {"status":"done"}
manage_dev_server.ps1 status # print running/stopped + Studio URL
#>
param([string]$Command = "status")
$SkillDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$PidFile = Join-Path $env:TEMP "langgraph_dev_server.pid"
$StdoutLog = Join-Path $env:TEMP "langgraph_dev_server.stdout.log"
$StderrLog = Join-Path $env:TEMP "langgraph_dev_server.stderr.log"
$ApiUrl = "http://127.0.0.1:2024"
$MaxWait = 45 # seconds to wait for the server to become ready
# --- helpers -----------------------------------------------------------
function Test-HttpOk([string]$Url) {
try {
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 300)
} catch {
return $false
}
}
function Get-StoredPid {
if (-not (Test-Path $PidFile)) { return 0 }
$raw = (Get-Content $PidFile -Raw -ErrorAction SilentlyContinue).Trim()
if (-not $raw) { return 0 }
[int]$raw
}
function Test-ServerRunning {
$storedPid = Get-StoredPid
if ($storedPid -eq 0) { return $false }
$proc = Get-Process -Id $storedPid -ErrorAction SilentlyContinue
if (-not $proc) { return $false }
return (Test-HttpOk "$ApiUrl/ok")
}
function Find-LangGraphExe {
$up = $env:USERPROFILE
$candidates = @(
"$up\.conda\envs\langgraph\Scripts\langgraph.exe",
"$up\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"$up\anaconda3\envs\langgraph\Scripts\langgraph.exe",
"$up\miniforge3\envs\langgraph\Scripts\langgraph.exe",
"$up\AppData\Local\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"C:\ProgramData\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"C:\ProgramData\anaconda3\envs\langgraph\Scripts\langgraph.exe"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return $c }
}
# Try PATH
$cmd = Get-Command langgraph -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
# Try resolving via conda
$condaCandidates = @($env:CONDA_EXE, "C:\ProgramData\miniconda3\Scripts\conda.exe",
"$up\AppData\Local\miniconda3\Scripts\conda.exe")
foreach ($conda in $condaCandidates) {
if ($conda -and (Test-Path $conda)) {
try {
$lgPath = (& $conda run -n langgraph python -c "import shutil; print(shutil.which('langgraph') or '')" 2>$null).Trim()
if ($lgPath -and (Test-Path $lgPath)) { return $lgPath }
} catch {}
}
}
throw ("Cannot find langgraph.exe in the 'langgraph' conda env. " +
"Install it: conda run -n langgraph pip install 'langgraph-cli[inmem]'")
}
function Stop-ServerProcess([int]$PidValue) {
$stopped = $false
if ($PidValue -gt 0) {
$proc = Get-Process -Id $PidValue -ErrorAction SilentlyContinue
if ($proc) {
Stop-Process -Id $PidValue -Force -ErrorAction SilentlyContinue
$stopped = $true
}
}
# Fallback: kill whoever is listening on port 2024
if (-not $stopped) {
$conn = Get-NetTCPConnection -LocalPort 2024 -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($conn) {
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
Write-Host "[dev-server] Stopped process on port 2024 (PID $($conn.OwningProcess))"
$stopped = $true
}
}
return $stopped
}
# --- command dispatch ---------------------------------------------------
switch ($Command) {
"start" {
if (Test-ServerRunning) {
$storedPid = Get-StoredPid
Write-Host "[dev-server] Already running (PID $storedPid) at $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
exit 0
}
$lgExe = Find-LangGraphExe
Write-Host "[dev-server] Starting langgraph dev server..."
Write-Host "[dev-server] exe: $lgExe"
Write-Host "[dev-server] dir: $SkillDir"
# Remove stale logs
Remove-Item $StdoutLog -ErrorAction SilentlyContinue
Remove-Item $StderrLog -ErrorAction SilentlyContinue
# Force UTF-8 to avoid GBK decode errors on Chinese Windows systems
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
$proc = Start-Process -FilePath $lgExe `
-ArgumentList "dev", "--no-browser" `
-WorkingDirectory $SkillDir `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-WindowStyle Hidden -PassThru
if (-not $proc) {
Write-Error "[dev-server] Failed to launch process"
exit 1
}
$proc.Id | Out-File -FilePath $PidFile -Encoding ascii -NoNewline
# Wait for server to respond
$elapsed = 0
while (-not (Test-HttpOk "$ApiUrl/ok") -and $elapsed -lt $MaxWait) {
Start-Sleep -Seconds 1
$elapsed++
if ($proc.HasExited) {
Write-Error "[dev-server] Process exited unexpectedly (code $($proc.ExitCode))"
Write-Host "--- stdout ($StdoutLog) ---"
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
Write-Host "--- stderr ($StderrLog) ---"
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
Remove-Item $PidFile -ErrorAction SilentlyContinue
exit 1
}
}
if ($elapsed -ge $MaxWait) {
Write-Error "[dev-server] Server did not become ready within ${MaxWait}s"
Write-Host "--- stdout ($StdoutLog) ---"
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
Write-Host "--- stderr ($StderrLog) ---"
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
exit 1
}
Write-Host "[dev-server] Ready in ${elapsed}s"
Write-Host "[dev-server] API: $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
Write-Host "[dev-server] Logs: $StdoutLog / $StderrLog"
}
"stop" {
$storedPid = Get-StoredPid
if ((Stop-ServerProcess -PidValue $storedPid)) {
Write-Host "[dev-server] Stopped (PID $storedPid)"
} else {
Write-Host "[dev-server] Not running (nothing to stop)"
}
Remove-Item $PidFile -ErrorAction SilentlyContinue
}
"status" {
if (Test-ServerRunning) {
$storedPid = Get-StoredPid
Write-Host "[dev-server] Running (PID $storedPid) at $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
} else {
Write-Host "[dev-server] Not running"
}
}
default {
Write-Error "Usage: manage_dev_server.ps1 {start|stop|status}"
exit 1
}
}
@@ -0,0 +1,173 @@
from __future__ import annotations
"""Codex review shim called by agents.py.
argv: task_path latest_discuss_path codex_session_id work_dir
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
def parse_jsonl(stdout: str) -> tuple[str, str]:
session_id = ""
final_message = ""
text_parts: list[str] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
event: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
text_parts.append(line)
continue
for key in (
"session_id",
"sessionId",
"thread_id",
"threadId",
"conversation_id",
"conversationId",
):
value = event.get(key)
if isinstance(value, str) and value:
session_id = value
event_type = str(event.get("type", ""))
if event_type in {"session.started", "session.created", "thread.started"}:
value = event.get("id") or event.get("session_id") or event.get("thread_id")
if isinstance(value, str) and value:
session_id = value
if event_type == "item.completed":
item = event.get("item")
if isinstance(item, dict) and item.get("type") == "agent_message":
value = item.get("text")
if isinstance(value, str) and value:
text_parts.append(value)
if event_type in {"agent_message", "message", "response.output_text.done"}:
value = event.get("text") or event.get("message") or event.get("content")
if isinstance(value, str) and value:
text_parts.append(value)
if event_type in {"exec.completed", "turn.completed", "task.completed"}:
value = event.get("last_message") or event.get("message") or event.get("output")
if isinstance(value, str) and value:
final_message = value
comment = (final_message or "\n".join(text_parts)).strip()
return session_id, comment
def resolve_codex() -> str:
import shutil
# 1. Explicit env var
exe = os.environ.get("CODEX_EXE", "")
if exe and Path(exe).exists():
return exe
# 2. Known install paths (checked before PATH to avoid picking up wrong codex variant)
for candidate in [
r"C:\Users\bowei\miniconda3\envs\langgraph\codex.cmd",
r"C:\Users\bowei\.conda\envs\langgraph\codex.cmd",
]:
if Path(candidate).exists():
return candidate
# 3. PATH lookup fallback
found = shutil.which("codex") or shutil.which("codex.cmd")
if found:
return found
raise FileNotFoundError(
"Cannot find codex executable. Set CODEX_EXE environment variable or add codex to PATH."
)
def main() -> int:
task_path = sys.argv[1]
latest_discuss = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else ""
codex_session_id = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else ""
work_dir = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else str(Path.cwd())
timeout = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5] else 300
prompt = (
f"Working directory: {work_dir}\n"
f"Review the plan document at {task_path}.\n"
"Only report severe issues.\n"
"Final answer rules: if there is no severe issue, output exactly NO_SERIOUS_ISSUE.\n"
"If there is a severe issue, briefly describe it in one paragraph.\n"
)
if latest_discuss:
prompt += f"Also verify that issues raised in {latest_discuss} were addressed.\n"
codex = resolve_codex()
fd, last_message_name = tempfile.mkstemp(
prefix="codex-last-message-",
suffix=".txt",
dir=None,
text=True,
)
os.close(fd)
last_message_path = Path(last_message_name)
try:
flags = ["--json", "--skip-git-repo-check", "--output-last-message", str(last_message_path)]
if codex_session_id:
command_args = ["cmd", "/c", codex, "exec", "resume"] + flags + [codex_session_id, "-"]
else:
command_args = ["cmd", "/c", codex, "exec"] + flags + ["-"]
logging.info("Invoking codex: %s", command_args[0] if command_args else "")
t0 = time.monotonic()
result = subprocess.run(
command_args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
input=prompt,
cwd=work_dir,
)
logging.info("Codex finished in %.1fs, exit=%d", time.monotonic() - t0, result.returncode)
session_id, stream_comment = parse_jsonl(result.stdout)
comment = last_message_path.read_text(encoding="utf-8").strip()
if not comment:
comment = stream_comment
if comment.strip() == "NO_SERIOUS_ISSUE":
comment = ""
if codex_session_id:
session_id = codex_session_id
elif not session_id:
session_id = codex_session_id
finally:
try:
last_message_path.unlink()
except FileNotFoundError:
pass
if result.returncode != 0 and not comment:
sys.stderr.write(result.stderr)
return result.returncode
print(json.dumps(
{
"has_serious_issue": bool(comment),
"comment": comment,
"codex_session_id": session_id,
},
ensure_ascii=False,
))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,104 @@
param(
[Parameter(Mandatory = $true)]
[string]$TaskId,
[string]$Prompt = "",
[string]$NotesDir = "Notes",
[Parameter(Mandatory = $true)]
[string]$CodexReviewCommand,
[string]$CondaExe = "",
[string]$CondaEnv = "langgraph",
[string]$CodexHome = "",
[int]$Timeout = 600,
[string]$ThreadId = "",
[string]$Resume = ""
)
function Resolve-CondaExe {
param([string]$ExplicitPath)
if ($ExplicitPath) {
if (-not (Test-Path -LiteralPath $ExplicitPath)) {
throw "Conda executable does not exist: $ExplicitPath"
}
return (Resolve-Path -LiteralPath $ExplicitPath).Path
}
if ($env:CONDA_EXE -and (Test-Path -LiteralPath $env:CONDA_EXE)) {
return (Resolve-Path -LiteralPath $env:CONDA_EXE).Path
}
$CondaCommand = Get-Command conda -ErrorAction SilentlyContinue
if ($CondaCommand -and $CondaCommand.Source) {
return $CondaCommand.Source
}
$UserProfile = [Environment]::GetFolderPath("UserProfile")
if ($UserProfile) {
$UserFallback = Join-Path $UserProfile "miniconda3\Scripts\conda.exe"
if (Test-Path -LiteralPath $UserFallback) {
return (Resolve-Path -LiteralPath $UserFallback).Path
}
}
$LegacyFallback = "C:\Users\bowei\miniconda3\Scripts\conda.exe"
if (Test-Path -LiteralPath $LegacyFallback) {
return $LegacyFallback
}
throw "Unable to locate conda. Set -CondaExe or CONDA_EXE, or ensure conda is on PATH."
}
function Resolve-CodexHome {
param([string]$ExplicitPath)
if ($ExplicitPath) {
return $ExplicitPath
}
if ($env:CODEX_HOME) {
return $env:CODEX_HOME
}
$UserProfile = [Environment]::GetFolderPath("UserProfile")
if (-not $UserProfile) {
throw "Unable to determine user profile for CODEX_HOME. Set -CodexHome or CODEX_HOME."
}
return (Join-Path $UserProfile ".codex")
}
$ErrorActionPreference = "Stop"
$LaunchWorkDir = (Get-Location).Path
Push-Location $PSScriptRoot
# Ensure conda run child processes can encode Unicode (Chinese, etc.) output
$env:PYTHONIOENCODING = "utf-8"
try {
$env:CODEX_HOME = Resolve-CodexHome -ExplicitPath $CodexHome
$Conda = Resolve-CondaExe -ExplicitPath $CondaExe
$ArgsList = @(
"run", "-n", $CondaEnv, "python", "run_workflow.py",
"--task-id", $TaskId,
"--notes-dir", $NotesDir,
"--work-dir", $LaunchWorkDir,
"--timeout", "$Timeout"
)
if ($Prompt) { $ArgsList += @("--prompt", $Prompt) }
$CodexReviewCommandB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CodexReviewCommand))
$ArgsList += @("--codex-review-command-b64", $CodexReviewCommandB64)
if ($ThreadId) { $ArgsList += @("--thread-id", $ThreadId) }
if ($Resume) {
$ResumeB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Resume))
$ArgsList += @("--resume-b64", $ResumeB64)
}
& $Conda @ArgsList
}
finally {
Pop-Location
}
@@ -0,0 +1,309 @@
"""run_workflow.py — submit workflow runs to the local langgraph dev server.
Instead of running the graph directly in-process, every invocation sends a
run to the langgraph dev server (http://127.0.0.1:2024) via the LangGraph
SDK sync client. This lets LangGraph Studio track execution in real time.
The Codex review command is forwarded in the run's config so the server does
not need to be restarted when the command changes between projects.
"""
from __future__ import annotations
import argparse
import base64
import json
import logging
import sys
import time
from pathlib import Path
SERVER_URL = "http://127.0.0.1:2024"
ASSISTANT_ID = "plan_review"
THREAD_MAP_FILENAME = "workflow_threads.json"
# ── path helpers ──────────────────────────────────────────────────────────────
def _resolve_work_dir(value: str) -> Path:
return Path(value or ".").resolve()
def _resolve_notes_dir(work_dir: Path, value: str) -> Path:
notes_dir = Path(value or "Notes")
if not notes_dir.is_absolute():
notes_dir = work_dir / notes_dir
return notes_dir.resolve()
# ── thread map (task_id → server thread_id) ──────────────────────────────────
def _load_thread_map(notes_dir: Path) -> dict:
path = notes_dir / THREAD_MAP_FILENAME
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_thread_map(notes_dir: Path, mapping: dict) -> None:
path = notes_dir / THREAD_MAP_FILENAME
path.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8")
# ── interrupt extraction ──────────────────────────────────────────────────────
def _extract_interrupt(state: dict):
"""Return the first interrupt value from a thread-state dict, or None."""
# Primary: tasks[].interrupts (langgraph-api >= 0.5)
for task in state.get("tasks", []):
for intr in task.get("interrupts", []):
if isinstance(intr, dict):
return intr.get("value", intr)
return intr
# Fallback: __interrupt__ channel value
interrupt_list = state.get("values", {}).get("__interrupt__", [])
if interrupt_list:
intr = interrupt_list[0]
return intr.value if hasattr(intr, "value") else intr
return None
def _is_interrupted(state: dict) -> bool:
"""Return True when the thread is paused at an interrupt."""
return bool(state.get("next")) and _extract_interrupt(state) is not None
# ── core workflow runner ──────────────────────────────────────────────────────
def run_workflow(
*,
task_id: str,
user_prompt: str = "",
notes_dir: str = "Notes",
work_dir: str = "",
codex_review_command: list[str],
thread_id: str | None = None,
resume_payload=None,
timeout: int = 600,
server_url: str = SERVER_URL,
) -> dict:
try:
from langgraph_sdk import get_sync_client
except ImportError:
raise ImportError(
"langgraph_sdk not installed. Run: "
"conda run -n langgraph pip install langgraph-sdk"
)
import httpx
effective_task_id = thread_id or task_id
resolved_work_dir = _resolve_work_dir(work_dir)
resolved_notes_dir = _resolve_notes_dir(resolved_work_dir, notes_dir)
resolved_notes_dir.mkdir(parents=True, exist_ok=True)
# ── verify server is up ───────────────────────────────────────────────────
try:
httpx.get(f"{server_url}/ok", timeout=5).raise_for_status()
except Exception as exc:
raise RuntimeError(
f"langgraph dev server not reachable at {server_url}.\n"
f"Start it first:\n"
f" PowerShell -File C:\\Users\\bowei\\.claude\\skills\\langgraph-plan-review\\scripts\\manage_dev_server.ps1 start\n"
f"Error: {exc}"
)
client = get_sync_client(url=server_url)
# ── resolve server thread_id ──────────────────────────────────────────────
thread_map = _load_thread_map(resolved_notes_dir)
server_thread_id: str | None = thread_map.get(effective_task_id)
# Verify the stored thread still exists on the server (server may have restarted)
if server_thread_id:
try:
client.threads.get(server_thread_id)
except Exception:
logging.warning(
"Stored thread %s no longer exists on server (server restarted?); "
"creating a new thread.",
server_thread_id,
)
server_thread_id = None
if not server_thread_id:
thread = client.threads.create(
metadata={"task_id": effective_task_id}
)
server_thread_id = thread["thread_id"]
thread_map[effective_task_id] = server_thread_id
_save_thread_map(resolved_notes_dir, thread_map)
# ── submit run ────────────────────────────────────────────────────────────
run_config = {
"configurable": {
"codex_review_command": codex_review_command,
"codex_timeout": timeout,
}
}
if resume_payload is not None:
run = client.runs.create(
server_thread_id,
ASSISTANT_ID,
command={"resume": resume_payload},
config=run_config,
)
else:
run = client.runs.create(
server_thread_id,
ASSISTANT_ID,
input={
"task_id": effective_task_id,
"user_prompt": user_prompt,
"notes_dir": str(resolved_notes_dir),
"work_dir": str(resolved_work_dir),
},
config=run_config,
)
run_id = run["run_id"]
logging.info("Submitted run %s on thread %s", run_id, server_thread_id)
# ── wait for completion or interrupt ──────────────────────────────────────
ACTIVE = {"pending", "running"}
deadline = time.monotonic() + timeout
run_status = run.get("status", "pending")
while run_status in ACTIVE and time.monotonic() < deadline:
time.sleep(1)
try:
final_run = client.runs.get(server_thread_id, run_id)
run_status = final_run.get("status", run_status)
except Exception as exc:
logging.debug("runs.get error (will retry): %s", exc)
if run_status in ACTIVE:
raise TimeoutError(
f"Run {run_id} did not complete within {timeout}s (status={run_status})"
)
# ── detect interrupt from thread state ────────────────────────────────────
state = client.threads.get_state(server_thread_id)
if _is_interrupted(state):
interrupt_value = _extract_interrupt(state)
return {
"status": "waiting",
"thread_id": effective_task_id,
"server_thread_id": server_thread_id,
"action_required": interrupt_value,
}
if run_status not in ("success",):
error = final_run.get("error") or run_status
raise RuntimeError(f"Run ended with status '{run_status}': {error}")
# ── extract final state ───────────────────────────────────────────────────
values = state.get("values", {})
return {
"status": "done",
"thread_id": effective_task_id,
"server_thread_id": server_thread_id,
"task_path": values.get("task_path", ""),
"latest_discuss_path": values.get("latest_discuss_path", ""),
"work_dir": values.get("work_dir", ""),
"codex_session_id": values.get("codex_session_id", ""),
"has_serious_issue": values.get("has_serious_issue", False),
"should_exit": values.get("should_exit", True),
}
# ── CLI ───────────────────────────────────────────────────────────────────────
def _parse_command(value: str | None) -> list[str] | None:
if not value:
return None
try:
parsed = json.loads(value)
except json.JSONDecodeError:
import re
try:
parsed = json.loads(re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value))
except json.JSONDecodeError:
stripped = value.strip()
if stripped.startswith("[") and stripped.endswith("]"):
parsed = [i.strip().strip('"').strip("'") for i in stripped[1:-1].split(",")]
else:
raise
if not isinstance(parsed, list) or not all(isinstance(i, str) for i in parsed):
raise argparse.ArgumentTypeError("command must be a JSON string array")
return parsed
def _parse_b64(value: str | None) -> list[str] | None:
if not value:
return None
return _parse_command(base64.b64decode(value).decode("utf-8"))
def _parse_json_b64(value: str | None):
if not value:
return None
return json.loads(base64.b64decode(value).decode("utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(
description="Submit a plan-review workflow run to the local langgraph dev server."
)
parser.add_argument("--task-id", required=True)
parser.add_argument("--prompt", default="")
parser.add_argument("--notes-dir", default="Notes")
parser.add_argument("--work-dir", default="")
parser.add_argument("--codex-review-command", type=_parse_command, default=None)
parser.add_argument("--codex-review-command-b64", type=_parse_b64, default=None)
parser.add_argument("--timeout", type=int, default=600)
parser.add_argument("--thread-id", default="")
parser.add_argument("--resume", default="")
parser.add_argument("--resume-b64", default="")
parser.add_argument("--server-url", default=SERVER_URL)
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
if args.verbose:
logging.basicConfig(
level=logging.DEBUG, stream=sys.stderr,
format="%(asctime)s %(levelname)s %(message)s",
)
codex_cmd = args.codex_review_command_b64 or args.codex_review_command
if not codex_cmd:
parser.error("--codex-review-command or --codex-review-command-b64 is required")
resume_payload = (
_parse_json_b64(args.resume_b64) if args.resume_b64
else (json.loads(args.resume) if args.resume else None)
)
output = run_workflow(
task_id=args.task_id,
user_prompt=args.prompt,
notes_dir=args.notes_dir,
work_dir=args.work_dir,
codex_review_command=codex_cmd,
thread_id=args.thread_id or None,
resume_payload=resume_payload,
timeout=args.timeout,
server_url=args.server_url,
)
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import sys
from pathlib import Path
import re
from typing import TypedDict
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt
from agents import AgentRunner
def _default_review_cmd() -> list[str]:
"""Return the real review_task.py command using the current interpreter."""
review_py = Path(__file__).parent / "review_task.py"
return [
sys.executable,
str(review_py),
"$task_path",
"$latest_discuss_path",
"$codex_session_id",
"$work_dir",
]
class WorkflowState(TypedDict):
task_id: str
user_prompt: str
work_dir: str
yy: int
notes_dir: str
task_path: str
latest_discuss_path: str
codex_session_id: str
has_serious_issue: bool
review_comment: str
should_exit: bool
claude_accepts: bool
rejection_reason: str
def format_yy(value: int) -> str:
return f"{value:02d}"
def discuss_path(notes_dir: str, task_id: str, yy: int) -> str:
return str(Path(notes_dir) / f"discuss{task_id}-{format_yy(yy)}.md")
def resolve_work_dir(value: str) -> Path:
return Path(value or ".").resolve()
def resolve_notes_dir(work_dir: Path, value: str) -> Path:
notes_dir = Path(value or "Notes")
if not notes_dir.is_absolute():
notes_dir = work_dir / notes_dir
return notes_dir.resolve()
def next_discuss_index(notes_dir: str, task_id: str) -> int:
notes = Path(notes_dir)
if not notes.exists():
return 0
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
indexes: list[int] = []
for path in notes.iterdir():
match = pattern.match(path.name)
if match:
indexes.append(int(match.group(1)))
return max(indexes, default=-1) + 1
def latest_discuss_path(notes_dir: str, task_id: str) -> str:
notes = Path(notes_dir)
if not notes.exists():
return ""
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
candidates: list[tuple[int, Path]] = []
for path in notes.iterdir():
match = pattern.match(path.name)
if match:
candidates.append((int(match.group(1)), path))
if not candidates:
return ""
return str(max(candidates, key=lambda item: item[0])[1])
def validate_task_file(task_path: str) -> None:
path = Path(task_path)
if not path.exists():
raise FileNotFoundError(
f"Task file does not exist. Current Claude Code session must write it first: {task_path}"
)
text = path.read_text(encoding="utf-8").strip()
if not text:
raise ValueError(f"Task file is empty: {task_path}")
# Count level-2 headings (lines starting with ## )
lines = text.split('\n')
level2_count = sum(1 for line in lines if line.startswith('## '))
if level2_count < 3:
raise ValueError(
f"Task file must contain at least 3 level-2 sections (## ...). Found: {level2_count}. "
f"File: {task_path}"
)
def validate_rejection_file(path: str) -> None:
rejection_path = Path(path)
if not rejection_path.exists():
raise FileNotFoundError(
"Rejection file was not written. Current Claude Code session must "
f"write it first: {path}"
)
text = rejection_path.read_text(encoding="utf-8").strip()
if not text:
raise ValueError(f"Rejection file is empty: {path}")
required_markers = ["Claude Code Rejection", "拒绝"]
if not any(marker in text for marker in required_markers):
raise ValueError(
f"Rejection file must contain a Claude rejection marker: {path}"
)
if len(text) < 30:
raise ValueError(
f"Rejection file is too short to contain a meaningful reason: {path}"
)
def values_from_state(state: WorkflowState) -> dict[str, str]:
return {
"task_id": state["task_id"],
"user_prompt": state["user_prompt"],
"work_dir": state["work_dir"],
"yy": format_yy(state["yy"]),
"notes_dir": state["notes_dir"],
"task_path": state["task_path"],
"discuss_path": discuss_path(state["notes_dir"], state["task_id"], state["yy"]),
"latest_discuss_path": state["latest_discuss_path"],
"codex_session_id": state["codex_session_id"],
}
def build_workflow(*, checkpointer=None):
graph = StateGraph(WorkflowState)
def initialize(state: WorkflowState) -> WorkflowState:
work_dir = resolve_work_dir(state.get("work_dir", ""))
notes_dir = resolve_notes_dir(work_dir, state.get("notes_dir") or "Notes")
task_id = state["task_id"]
task_path = str(notes_dir / f"Task{task_id}.md")
yy = next_discuss_index(str(notes_dir), task_id)
latest_path = latest_discuss_path(str(notes_dir), task_id)
return {
**state,
"work_dir": str(work_dir),
"yy": yy,
"notes_dir": str(notes_dir),
"task_path": task_path,
"latest_discuss_path": latest_path,
"codex_session_id": state.get("codex_session_id", ""),
"has_serious_issue": False,
"review_comment": "",
"should_exit": False,
"claude_accepts": False,
"rejection_reason": "",
}
def request_plan(state: WorkflowState) -> WorkflowState:
interrupt({
"action": "write_plan",
"task_path": state["task_path"],
"user_prompt": state["user_prompt"],
})
validate_task_file(state["task_path"])
return state
def review(state: WorkflowState, config: RunnableConfig) -> WorkflowState:
cfg = config.get("configurable", {})
codex_cmd: list[str] = cfg.get("codex_review_command") or _default_review_cmd()
timeout: int = int(cfg.get("codex_timeout", 600))
runner = AgentRunner(codex_review_command=codex_cmd, timeout=timeout)
result = runner.review(values_from_state(state))
updates = {
**state,
"has_serious_issue": result.has_serious_issue,
"review_comment": result.comment,
"should_exit": not result.has_serious_issue,
}
if result.codex_session_id:
updates["codex_session_id"] = result.codex_session_id
return updates
def write_discuss(state: WorkflowState) -> WorkflowState:
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
comment = state["review_comment"].strip()
if not comment:
comment = "Codex reported a serious issue but did not provide details."
Path(path).write_text(
"\n".join([
f"# discuss{state['task_id']}-{format_yy(state['yy'])}",
"",
"## Codex Review",
"",
comment,
"",
]),
encoding="utf-8",
)
return {
**state,
"latest_discuss_path": path,
"yy": state["yy"] + 1,
}
def request_decision(state: WorkflowState) -> WorkflowState:
result = interrupt({
"action": "decide",
"discuss_path": state["latest_discuss_path"],
})
if not isinstance(result, dict):
result = {}
return {
**state,
"claude_accepts": bool(result.get("accepts", False)),
"rejection_reason": str(result.get("reason", "")),
}
def request_revision(state: WorkflowState) -> WorkflowState:
interrupt({
"action": "revise",
"task_path": state["task_path"],
"discuss_path": state["latest_discuss_path"],
})
validate_task_file(state["task_path"])
return state
def request_rejection(state: WorkflowState) -> WorkflowState:
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
interrupt({
"action": "write_rejection",
"output_path": path,
"reason_hint": state["rejection_reason"],
"required_heading": "## Claude Code Rejection",
})
validate_rejection_file(path)
return {
**state,
"latest_discuss_path": path,
"yy": state["yy"] + 1,
}
def review_route(state: WorkflowState) -> str:
return "done" if state["should_exit"] else "write_discuss"
def decision_route(state: WorkflowState) -> str:
return "revise" if state["claude_accepts"] else "reject"
graph.add_node("initialize", initialize)
graph.add_node("request_plan", request_plan)
graph.add_node("review", review)
graph.add_node("write_discuss", write_discuss)
graph.add_node("request_decision", request_decision)
graph.add_node("request_revision", request_revision)
graph.add_node("request_rejection", request_rejection)
graph.add_edge(START, "initialize")
graph.add_edge("initialize", "request_plan")
graph.add_edge("request_plan", "review")
graph.add_conditional_edges(
"review",
review_route,
{"done": END, "write_discuss": "write_discuss"},
)
graph.add_edge("write_discuss", "request_decision")
graph.add_conditional_edges(
"request_decision",
decision_route,
{"revise": "request_revision", "reject": "request_rejection"},
)
graph.add_edge("request_revision", "review")
graph.add_edge("request_rejection", "review")
return graph.compile(checkpointer=checkpointer)
+454
View File
@@ -0,0 +1,454 @@
---
name: langgraph-vivado-install
description: 在 Windows 原生环境下自动化配置基于 Conda 的 LangGraph 开发环境,并强制完成 Vivado 链路的闭环验证。当用户提到"配置langgraph"、"安装langgraph"、"LangGraph环境"、"langgraph vivado"、"配置开发环境 langgraph"、"LangGraph+Vivado"、"搭建langgraph"时,必须触发此技能。即使用户未明确说"用这个技能",只要涉及在 Windows 上安装或配置 LangGraph(含与 Vivado 集成),都应触发。
---
# LangGraph + Vivado 环境配置技能
本技能在 Windows 原生环境(Git Bash / 命令提示符)中自动完成以下工作:
1. 检测并安装 Conda
2. 确认 conda 路径已永久写入 Windows 用户级 PATH
3. 创建 `langgraph` 虚拟环境并安装所需包
4. 迭代检测 Vivado 链路直至成功
5. 写出配置文件与冒烟测试脚本并运行验证
---
## 整体流程
```
Step 1: Conda 检测与安装
Step 1b: 确认 conda 路径已写入 Windows PATH(新安装或已有均执行)
Step 2: 创建 langgraph 环境,安装依赖
Step 2c: 检测并安装 Codex CLI,验证 Auth
Step 3: Vivado 链路迭代检测(循环直到成功)
Step 4: 写出 env_config.json,提示持久化
Step 5: 生成并运行 smoke_test.py
```
---
## Step 1 — Conda 检测与安装
运行:
```bash
conda --version
```
- **若成功**:记录版本,进入 **Step 1b**。
- **若失败**:执行安装命令:
```bash
winget install -e --id Anaconda.Miniconda3
```
安装完成后,提示用户:
> "Miniconda 安装完成。请重启 Shell 或运行 `conda init bash` 使 conda 命令生效,然后重新触发本技能。"
**在用户确认 conda 可用之前,不继续执行后续步骤。用户确认后,进入 Step 1b。**
---
## Step 1b — 确认 conda 路径已写入 Windows PATH
无论 conda 是刚安装的还是之前已存在,都必须执行此步骤,确保 conda 的路径被永久写入 Windows 用户级 PATH,方便在任何新 Shell 中直接使用。
### 1. 定位 conda 所在目录
在 PowerShell 中运行:
```powershell
$condaPath = (Get-Command conda -ErrorAction SilentlyContinue).Source
$condaBin = if ($condaPath) { Split-Path $condaPath -Parent } else { $null }
Write-Host "conda 所在目录: $condaBin"
```
若 `$condaBin` 为空(conda 命令本身来自 conda init 脚本而非独立可执行文件),则改用:
```powershell
# 方法 B:通过 conda info 获取 base 目录,再拼接 condabin
$baseDir = (conda info --base 2>$null).Trim()
$condaBin = Join-Path $baseDir "condabin"
Write-Host "conda condabin 目录: $condaBin"
```
### 2. 检查路径是否已在 PATH 中
```powershell
$userPath = [System.Environment]::GetEnvironmentVariable("PATH", "User")
$sysPath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine")
$inUserPath = ($userPath -split ";") -contains $condaBin
$inSysPath = ($sysPath -split ";") -contains $condaBin
if ($inUserPath -or $inSysPath) {
Write-Host "[OK] conda 路径已在 PATH 中,无需修改。"
} else {
Write-Host "[INFO] conda 路径不在 PATH 中,准备写入用户级 PATH..."
}
```
### 3. 若不在 PATH 中则写入
```powershell
if (-not ($inUserPath -or $inSysPath)) {
$newUserPath = $condaBin + ";" + $userPath
[System.Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")
Write-Host "[OK] 已将以下路径写入用户级 PATH: $condaBin"
Write-Host " 新开终端后即可直接使用 conda 命令。"
}
```
> **为什么写用户级 PATH 而非系统级?** 用户级 PATH 不需要管理员权限,且只影响当前用户,更安全。若该机器是多用户共享环境,可改为 `"Machine"` 并以管理员身份运行。
完成后进入 Step 2。
---
## Step 2 — 创建 langgraph Conda 环境
### 2a. 检查环境是否已存在
```bash
conda env list | grep "^langgraph"
```
- 若已存在:跳过创建,直接进入 2b。
- 若不存在:
```bash
conda create -n langgraph python=3.11 -y
```
### 2b. 安装所需 Python 包
一条命令安装全部依赖(包含 `/langgraph-plan-review` skill 所需包):
```bash
conda run -n langgraph pip install \
langgraph \
langchain-anthropic \
pandas \
"langgraph-cli[inmem]" \
langgraph-sdk \
httpx \
langgraph-checkpoint-sqlite
```
> **为什么这些包?**
> | 包 | 用途 |
> |---|---|
> | `langgraph` | 核心图执行引擎 |
> | `langchain-anthropic` | Anthropic LLM 集成 |
> | `pandas` | 数据处理 |
> | `langgraph-cli[inmem]` | 提供 `langgraph dev` 服务器命令(`/langgraph-plan-review` 必需) |
> | `langgraph-sdk` | `run_workflow.py` 通过 SDK 向 dev server 提交 run(`/langgraph-plan-review` 必需) |
> | `httpx` | `run_workflow.py` 健康检查(`/langgraph-plan-review` 必需) |
> | `langgraph-checkpoint-sqlite` | dev server 状态持久化(`/langgraph-plan-review` 必需) |
验证安装:
```bash
conda run -n langgraph python -c "import langgraph; print('LangGraph OK:', langgraph.__version__)"
conda run -n langgraph python -c "from langgraph_sdk import get_sync_client; print('langgraph-sdk OK')"
conda run -n langgraph python -c "import httpx; print('httpx OK')"
conda run -n langgraph langgraph --version
```
若任一导入失败,报告错误并停止,请用户检查网络或 pip 源配置。
---
## Step 2c — 检测并安装 Codex CLI,验证 Auth
Codex CLI 是 OpenAI 发布的命令行代码审查工具,以 npm 全局包形式安装在 `langgraph` conda 环境中,供 LangGraph 工作流的 Codex review 节点调用。
### 2c-1. 检测 Codex CLI 是否已安装
在 PowerShell 中运行:
```powershell
$condaEnvPath = (C:\Users\bowei\miniconda3\Scripts\conda.exe run -n langgraph python -c "import sys; print(sys.prefix)").Trim()
$codexCmd = Join-Path $condaEnvPath "codex.cmd"
if (Test-Path $codexCmd) {
Write-Host "[OK] Codex CLI 已安装: $codexCmd"
$codexInstalled = $true
} else {
Write-Host "[INFO] 未检测到 Codex CLI,准备安装..."
$codexInstalled = $false
}
```
### 2c-2. 若未安装,通过 npm 安装
首先确认 npm 可用(Node.js 需已安装在 conda 环境或系统 PATH 中):
```powershell
C:\Users\bowei\miniconda3\Scripts\conda.exe run -n langgraph npm --version
```
- **若 npm 可用**:执行安装:
```powershell
C:\Users\bowei\miniconda3\Scripts\conda.exe run -n langgraph npm install -g @openai/codex
```
安装完成后,再次执行 2c-1 检查 `codex.cmd` 是否出现。若仍不存在,报告错误并停止,请用户手动检查 npm 全局安装路径。
- **若 npm 不可用**:停止并提示:
> "langgraph conda 环境中未找到 npm。请先安装 Node.js(建议通过 `winget install OpenJS.NodeJS.LTS`),重启 Shell 后重新触发本技能。"
**在用户确认 npm 可用之前,不继续后续步骤。**
### 2c-3. 验证 Codex CLI 可执行
```powershell
$env:CODEX_HOME = "C:\Users\bowei\.codex"
cmd /c "$codexCmd" --version
```
- 若命令输出版本号(如 `0.133.0`):记录版本,继续。
- 若命令失败:报告错误(输出内容),停止执行,请用户检查 Node.js 版本兼容性。
> **为什么用 `cmd /c`?** `.cmd` 文件在 Windows 上不能直接被 PowerShell 或 Python subprocess 执行,必须通过 `cmd /c` 转发。
### 2c-4. 验证 Codex Auth
Codex CLI 的认证 token 存储在 `$CODEX_HOME\auth.json`(默认 `C:\Users\bowei\.codex\auth.json`)。
```powershell
$codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { "$env:USERPROFILE\.codex" }
$authFile = Join-Path $codexHome "auth.json"
if (Test-Path $authFile) {
$authContent = (Get-Content $authFile -Raw -Encoding utf8).Trim()
if ($authContent.Length -gt 20) {
Write-Host "[OK] Codex auth.json 存在且内容有效。"
$authOk = $true
} else {
Write-Host "[WARN] auth.json 内容过短,可能已损坏或未完成认证。"
$authOk = $false
}
} else {
Write-Host "[WARN] 未找到 auth.json: $authFile"
$authOk = $false
}
```
- **若 `$authOk = $true`**:继续进入 Step 3。
- **若 `$authOk = $false`**:停止并提示:
> "Codex CLI 尚未完成认证,或认证已失效。请在终端运行 `codex` 并按提示完成 ChatGPT OAuth 登录,登录成功后 auth.json 将自动写入 `C:\Users\bowei\.codex\`。完成后重新触发本技能或手动继续到 Step 3。"
**在用户确认认证完成之前,不继续后续步骤。**
> **为什么检查 auth.json 而非运行 `codex auth status`?** Codex CLI 目前没有独立的状态查询子命令;auth.json 存在且非空即表示 OAuth 流程已完成。若需更严格验证,可在 Step 5 冒烟测试中加入一次受控的 `codex exec --ephemeral` 调用。
---
## Step 3 — Vivado 链路迭代检测循环
这是本技能的核心环节。目标是确保在 `langgraph` 环境中可以调用 Vivado。
### Step A:首次尝试检测
```bash
conda run -n langgraph vivado -version
```
### Step B:若成功
- 打印版本信息(取输出第一行)。
- 进入 Step 4。
### Step C:若失败(命令未找到)— 交互式路径收集
**停止自动化**,向用户提问:
> "未能检测到 Vivado 环境。请输入 Vivado 的 bin 文件夹完整路径(例如:`C:\Xilinx\Vivado\2023.2\bin`):"
**收到用户输入后执行:**
1. **验证路径有效性** — 检查该目录下 `vivado.bat` 是否存在:
```bash
# 将用户输入的 Windows 路径转换为 bash 路径后检查
# 例:C:\Xilinx\Vivado\2023.2\bin → /c/Xilinx/Vivado/2023.2/bin
ls "<converted_path>/vivado.bat"
```
- 若不存在:提示 "路径无效或找不到 vivado.bat,请重新输入。" 并**重复 Step C**。
- 若存在:继续。
2. **将路径临时加入当前会话 PATH**(Windows 反斜杠转为正斜杠):
```bash
export PATH="<converted_path>:$PATH"
```
3. **记录该路径**供后续 Step 4/5 使用(内部变量 `VIVADO_BIN_PATH`)。
4. **重试检测**:
```bash
vivado -version
```
- 若成功:打印版本,进入 Step 4。
- 若再次失败:回到 Step C 顶部,重新向用户询问路径。
> **循环逻辑**:重复 Step C 直到 `vivado -version` 执行成功为止。不要跳过,不要假设路径正确。
---
## Step 4 — 持久化配置
`vivado -version` 成功后:
### 4a. 提示用户持久化
向用户说明:
> "为了在每次新开 Shell 时自动识别 Vivado,建议将该路径永久添加到 Windows 系统环境变量 PATH 中。操作方法:系统属性 → 高级 → 环境变量 → 编辑 PATH → 新建,粘贴路径。"
### 4b. 写出 env_config.json
在当前工作目录创建:
```json
{
"VIVADO_BIN_PATH": "<Step 3 确认的 Windows 格式路径>",
"conda_env": "langgraph",
"python_version": "3.11",
"packages": [
"langgraph",
"langchain-anthropic",
"pandas",
"langgraph-cli[inmem]",
"langgraph-sdk",
"httpx",
"langgraph-checkpoint-sqlite"
]
}
```
---
## Step 5 — 生成冒烟测试脚本并运行
在当前工作目录创建 `smoke_test.py`,内容如下:
```python
import subprocess
import sys
import json
import os
def test_langgraph():
try:
import langgraph
import importlib.metadata
version = importlib.metadata.version("langgraph")
print(f"[OK] LangGraph import OK, version: {version}")
return True
except ImportError as e:
print(f"[FAIL] LangGraph import failed: {e}")
return False
def test_langgraph_plan_review_deps():
"""Verify packages required by the /langgraph-plan-review skill."""
ok = True
checks = [
("langgraph_sdk", "from langgraph_sdk import get_sync_client"),
("httpx", "import httpx"),
("langgraph_checkpoint_sqlite", "from langgraph.checkpoint.sqlite import SqliteSaver"),
]
for name, stmt in checks:
try:
exec(stmt)
import importlib.metadata
try:
ver = importlib.metadata.version(name.replace("_", "-"))
print(f"[OK] {name} OK (version: {ver})")
except importlib.metadata.PackageNotFoundError:
print(f"[OK] {name} importable")
except Exception as e:
print(f"[FAIL] {name}: {e}")
ok = False
# Check langgraph dev CLI
try:
result = subprocess.run(
[sys.executable, "-m", "langgraph", "--version"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
print(f"[OK] langgraph-cli OK: {result.stdout.strip() or result.stderr.strip()}")
else:
# Try via entry point script
import shutil
lg = shutil.which("langgraph")
if lg:
r2 = subprocess.run([lg, "--version"], capture_output=True, text=True, timeout=10)
if r2.returncode == 0:
print(f"[OK] langgraph dev CLI OK: {r2.stdout.strip()}")
else:
print(f"[WARN] langgraph CLI found but --version failed: {r2.stderr.strip()}")
else:
print("[FAIL] langgraph CLI not found on PATH — install: pip install 'langgraph-cli[inmem]'")
ok = False
except Exception as e:
print(f"[FAIL] langgraph CLI check error: {e}")
ok = False
return ok
def test_vivado():
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "env_config.json")
env = os.environ.copy()
vivado_bin = ""
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
vivado_bin = config.get("VIVADO_BIN_PATH", "").strip()
if vivado_bin:
env["PATH"] = vivado_bin + os.pathsep + env["PATH"]
# On Windows use the full path to vivado.bat to avoid shell PATH issues
if vivado_bin and (os.name == "nt" or sys.platform == "win32"):
cmd = [os.path.join(vivado_bin, "vivado.bat"), "-version"]
else:
cmd = ["vivado", "-version"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
if result.returncode == 0:
first_line = (result.stdout.strip().splitlines() or ["(no output)"])[0]
print(f"[OK] Vivado detected: {first_line}")
return True
else:
print(f"[FAIL] vivado -version non-zero exit: {result.stderr.strip()}")
return False
except FileNotFoundError:
print(f"[FAIL] vivado not found. CMD tried: {cmd}")
return False
except subprocess.TimeoutExpired:
print("[FAIL] vivado -version timed out (30s)")
return False
if __name__ == "__main__":
print("=" * 50)
print("LangGraph + Vivado smoke test")
print("=" * 50)
ok_lg = test_langgraph()
print("-" * 50)
print("langgraph-plan-review deps:")
ok_pr = test_langgraph_plan_review_deps()
print("-" * 50)
ok_vv = test_vivado()
print("=" * 50)
if ok_lg and ok_pr and ok_vv:
print("[PASS] All checks passed. LangGraph + Vivado + plan-review pipeline OK!")
sys.exit(0)
else:
print("[FAIL] Some checks failed. See above.")
sys.exit(1)
```
### 运行冒烟测试
```bash
conda run -n langgraph python smoke_test.py
```
- **全部通过**:向用户报告"环境配置完成,LangGraph + Vivado 链路闭环成功。"
- **有失败项**:读取具体错误,向用户说明原因(路径问题、包未安装等),并提供修复建议。
---
## 注意事项
- Windows 路径转换规则:`C:\Xilinx\Vivado\2023.2\bin` → `/c/Xilinx/Vivado/2023.2/bin`(首字母小写,冒号去掉,反斜杠换正斜杠)。
- `conda run -n langgraph vivado -version` 需要 PATH 在 conda 激活前已包含 Vivado bin 目录,若不行则用 `export PATH=... && conda run ...` 形式或直接用绝对路径调用。
- 本技能产生的文件:`env_config.json`、`smoke_test.py`(均在调用时的当前目录)。
+151
View File
@@ -0,0 +1,151 @@
---
name: smod_atsc_dev
description: >
在 SMOD 硬件平台(Artix-7 xc7a200t + i.MX6 + ADRV9009)上开发、调试、固化 ATSC 激励器:
改 RTL 并用 vivado-mcp 综合实现、在 Ubuntu VM 上交叉编译 imx6 的 atsc_app 并部署、
用 ILA/串口/频谱做闭环验证。(生成 quickboot .mcs 和远程升级 FPGA 转交 fpga_update skill。)
只要用户提到 SMOD、my_exciter、RU_golden、smod_top、atsc_app、ATSC 激励器、8VSB 调制、
ADRV9009/ORx/DPD/TxAGC、tuner(Sony CXD2878)、TSoIP、quickboot、远程升级 mcs、192.168.1.143、
或在 E:\Xilinx_design\smod\SMOD_ATSC 下做任何 FPGA / imx6 改动,就必须使用这个 skill —— 即便
他们没点名 "skill",也没说 "SMOD",只是说 "把新 bit 升上去"、"改一下激励器的 RTL"、
"imx6 app 编译部署一下"、"抓个 ILA 看看"、"重新出个 mcs"。这个 skill 给出平台事实、
两条烧写路径的选择、上电时序铁律,并把闭环调试转交给 fpga-imx6-flow。
---
# SMOD ATSC 激励器开发
SMOD 是一块 **Artix-7 (xc7a200tfbg676-2) + i.MX6 + ADRV9009** 的 ATSC 激励器板。
FPGA 做 8VSB 调制 / DPD / AGC / TS 前处理,imx6 跑 `atsc_app`(配置 ADRV9009、tuner、
网页、SNMP),两者通过一个 I2C/寄存器桥通信。
这个 skill 是**总入口**:它告诉你平台的硬事实、该走哪条烧写路径、以及什么时候把活
交给别的 skill。具体细节在 `references/`,可直接运行的脚本在 `scripts/`。
## 平台事实速查
| 项 | 值 |
|---|---|
| 激励器 Vivado 工程 | `E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\my_exciter.xpr`(top = `smod_top`,也是 **git 仓库根**) |
| 打底 golden 工程 | `E:\Xilinx_design\smod\SMOD_ATSC\RU_golden\RU_golden.xpr`(top = `RU_golden_top`) |
| FPGA / Flash | `xc7a200tfbg676-2` / Winbond **w25q128bv**(128 Mbit = 16 MB,SPIx1) |
| Vivado | `D:\Xilinx\Vivado\2018.1`(`bin\vivado.bat`) |
| imx6 源码 | `my_exciter\imx6\src\atsc_app`(Makefile 在该目录根) |
| imx6 设备 | `192.168.1.143`,`root` / `anywavecom`(网页 + SSH;Windows 主机 `192.168.1.125` 同网段) |
| 交叉编译 VM | `bowei@192.168.223.128`,密码 `lr7402`,构建目录 `~/atsc_head_build` |
| 工具链 | `/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi` |
| app 部署路径 | `/aw/anywave/app/imx_atsc_app`(先写 `imx_atsc_app_head`,再 `cp -a` 覆盖 active) |
| app 日志 | `/var/module_log/imx_atsc_app.log` |
| 串口 | COM 号**逐机而异,两处配置都在 `~` 下、都不入库**:两个继电器(电源 / USB-Blast)看 `~/vivado_mcp_usb.conf`(缺了就跑 vivado-mcp 的 `detect_serial_relays` 自动探测生成);imx6 控制台看 `~/fpga_imx6_flow.json` 的 `ports.imx6`。**任何地方都不要写死 COM 号。** |
| 原理图 | `my_exciter\Notes\SMOD_V1.0_SCH251010.PDF` |
| 构建产物约定 | `my_exciter\bitstream\<tag>.{bit,ltx,mcs,prm}`,一次迭代一个 tag(`smod_p1`、`smod_dpd`…) |
## ⚠️ 硬件铁律:上电时序
**给 SMOD 上电/断电之前,USB-Blast 必须先断开。** 绝不在 USB-Blast 连接状态下切换板卡电源
——这会打坏 JTAG 适配器和板卡。
```
断电(power_off) → 断开 USB-Blast(USB_Blast_off) → 上电(power_on) → 连接 USB-Blast(USB_Blast_on) → JTAG/ILA
```
用 vivado-mcp 的 `power_on` / `power_off` / `USB_Blast_on` / `USB_Blast_off` 工具执行。
上电后要**等 imx6 串口控制台打印 `sys init over` 再连 JTAG**,否则 TX 链路会挂掉。
## 两条烧写路径 —— 先选对,再动手
这是本平台最容易搞错的地方。板上 flash 是 **quickboot 双镜像**布局:golden 镜像在
`0x000000..0x3FFFFF`,update 镜像在 `0x400000..0xBFFFFF`,切换字 `0xAA995566` 在 `0x000FFC`。
| | **A. JTAG 直烧** | **B. 网络远程升级**(PR07 主线) |
|---|---|---|
| 何时用 | 板子是砖 / 首次装机 / 要换 golden | 日常迭代,板子已有 golden 且网络能通 |
| 烧什么 | `RU_golden\bitstream\golden_design_initial.mcs` | `my_exciter\bitstream\<tag>_update.mcs` |
| 怎么做 | `/fpga_flash` skill(flash 型号 `w25q128bv`) | **`/fpga_update` skill** |
| 需要 | USB-Blast + 上电时序 | 只要 `192.168.1.143` 能访问 |
| 风险 | 高(要动电源和 JTAG) | 低(写坏了 golden 会兜底) |
**调试期**还有第三种:`program_device` 直接把 `.bit` 下到 FPGA(掉电即失),配合 `.ltx` 抓 ILA。
迭代阶段用它最快,验证通过后再走 A 或 B 固化。
### B 走 `/fpga_update`
**远程升级不走网页。** 网页的 Remote Update 只认 imx6 的 `*.tar.gz` 固件包,`.mcs` 传上去会被
`check_package.sh` 删掉。FPGA 走的是 TCP **1234** 上的私有协议。生成 mcs、烧写、排错的完整流程
都在 `fpga_update` skill 里,包括两个必须知道的事实:
- **擦除卡住 ≠ 还在擦。** imx6 的 `POLL_ERASER` 不看 ERROR 位,programmer 报错和正常忙碌
返回同一个 `ERASER_ING`。读设备日志的 `rstate` 才知道真相(`0x7`=已中止,`0x0`=真在擦)。
- `-imagesize 64` 和 `SpiFlashProgrammer.vhd` 里的 `cAddrUpdateStart/End` 是一对,别改。
升级完成后**断电冷启动**,让 FPGA 从 flash 重新加载,再验证。
## 三条典型工作流
**只改 RTL** → 编辑 `my_exciter.srcs/sources_1/**` → vivado-mcp `run_synthesis` +
`run_implementation` + `generate_bitstream` → `program_device` 下 bit → 抓 ILA 验证 →
满意后走 `/fpga_update` 固化。
**只改 imx6** → 编辑 `imx6/src/atsc_app/**` → 提交到 git(脚本默认打包 `HEAD`)→
```bash
python .../scripts/imx6_app.py build-deploy --tag mychange
```
→ 冷启动让 `rc.local` 拉起新 app → `imx6_app.py log` 看日志。
**FPGA + imx6 联调** → 交给 **`/fpga-imx6-flow`**。那个 skill 驱动一张 LangGraph 图,
把上电/JTAG/烧写/重启做成确定性节点,把「分析 → 判断是否达标 → 提方案 → 改 FPGA / 改 imx6」
这四步作为中断交回当前会话执行,一轮一轮迭代。PR07 明确要求走这条路做联合调试。
## 什么时候调别的 skill
| 场景 | 用 |
|---|---|
| 上电 / JTAG / 综合实现 / 抓 ILA / 下 bit | **vivado-mcp** 工具(`power_on`、`program_device`、`run_tcl`…) |
| 闭环设计-调试迭代(本 skill 的主推荐) | **`/fpga-imx6-flow`** |
| JTAG 烧 flash(golden 或砖机恢复) | **`/fpga_flash`**(flash 型号选 `w25q128bv`) |
| 单模块 RTL 仿真 / 出 testbench | **`/vivado-sim`** |
| ILA 抓出来的 IQ CSV 做频谱 / OBW / 带肩 | **`/fft-analyse`** |
| 网页操作(登录、升级、看频谱页) | **claude-in-chrome** |
| 实测频谱(N9320B 频谱仪) | `mcp__mcp_n9320b__*` 工具 |
## scripts/
| 脚本 | 用途 |
|---|---|
| `imx6_app.py` | `build` / `deploy` / `build-deploy` / `log` —— VM 交叉编译 + 推到 imx6 + 读日志 |
出 `.mcs` 和远程升级的脚本在 `/fpga_update` skill 里(`gen_update_mcs.py`、`fpga_ru.py`)。
两个脚本都 `--help` 可查。`imx6_app.py deploy` 会自动装 paramiko 2.12 到缓存目录并 re-exec
(imx6 的 dropbear 太老,paramiko 5 连不上,见下)。
## references/
按需读,别一次全读进来:
- `references/fpga.md` —— RTL 目录结构、XDC、vivado-mcp 用法、ILA/ltx 约定、已知时序告警的性质
- `references/imx6.md` —— app 源码结构、交叉编译细节、部署/日志/寄存器访问、网页
- `references/upgrade.md` —— quickboot 双镜像布局、mcs 生成的确切参数、golden 恢复
- `references/history.md` —— 历史 PR/Task 笔记索引(tuner、TSoIP、ORx、DPD、AGC 各自踩过什么坑)
## 常见坑
| 症状 | 原因 / 处理 |
|---|---|
| 远程升级后重新上电 FPGA 不跑 | 传错了文件 —— 必须是 `_update.mcs`(起始 `0x800000`),不是 `write_cfgmem` 出的基础 mcs |
| 远程升级卡在 "erasing" 不动 | programmer 已报错退出(`rstate=0x7`),imx6 的轮询不看 ERROR 位。见 `/fpga_update` |
| imx6 SSH 报 `unknown cipher` / `no acceptable host key` | dropbear 太老。用 paramiko **2.12**(`imx6_app.py` 已处理);命令行 ssh 加 `-o HostKeyAlgorithms=+ssh-rsa` |
| imx6 `open_sftp()` → `EOF during negotiation` | 设备没有 SFTP 子系统。用 `cat > file` 走 exec channel 传文件 |
| VM SSH `kex_exchange_identification: Connection closed` | VM 的 sshd 挂了,请用户 `sudo systemctl restart sshd` |
| 上电后 TX 链路死掉 | 上电后没等 `sys init over` 就连了 JTAG |
| `power_off` 卡在循环里 | 先 `USB_Blast_off` 再 `power_off`,顺序反了 |
| WNS ≈ -9 ~ -13 ns | JESD204 `init_seq_data` 的既有 false path,与功能无关,不要为它改设计 |
| `i2cget` 读 FPGA 寄存器时好时坏 | 带调试 ILA 的 bit 时序更差;且会和 app 抢总线。以生产 bit 为准,或从 app 日志读 |
## 写笔记
这个工程的约定是 `my_exciter\Notes\PRxx.md` 记**需求**,`Taskxx.md` 记**方案与实现总结**
(含验证数据和踩过的坑)。做完一轮有结论的工作,补一份 `Taskxx.md` —— 后来的人(和后来的你)
靠它避免重复踩坑。`references/history.md` 是已有笔记的索引。
+81
View File
@@ -0,0 +1,81 @@
# FPGA 侧(my_exciter / RU_golden)
## 工程
| | my_exciter | RU_golden |
|---|---|---|
| xpr | `E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\my_exciter.xpr` | `...\RU_golden\RU_golden.xpr` |
| top | `smod_top`(`my_exciter.srcs/sources_1/top/smod_top.v`,~2300 行) | `RU_golden_top` |
| 作用 | ATSC 激励器全功能 | 打底镜像:网口 + quickboot 自烧,用来兜底和支撑远程升级 |
| 器件 | xc7a200tfbg676-2 | 同左 |
`my_exciter` 目录本身是 **git 仓库根**(`imx6/` 也在里面)。RU_golden 的 xpr 引用了
my_exciter 里的少数源文件(`ts_lock_top.vhd`、`sdram_tsoip_top.vhd`),改动时留意。
## RTL 目录(`my_exciter.srcs/sources_1/`)
| 目录 | 内容 |
|---|---|
| `top/` | `smod_top.v` 顶层 + 时钟/AGC/DA 前处理/ORx 抽取等杂项 |
| `atsc_mod_xip/` | 8VSB 调制核(加密 IP) |
| `dpd/` | 数字预失真 |
| `txagc/` | 发射 AGC |
| `asi_pre/`, `dvb_asi/`, `ts/` | ASI / TS 输入与包同步(`src_preproc`、`spi_lock_det`) |
| `tsoip/`, `tsoip_atsc/`, `udp/`, `sdram/` | TS over IP 收发与缓存 |
| `tuner/` | Sony CXD2878 tuner 的 TS 接口(`TUN_D[7:0]/TUN_VLD/TUN_CLK`) |
| `quickboot/` | `SpiFlashProgrammer.vhd` + `SpiSerDes.vhd` + `spi_slave32.v` —— 远程升级的 FPGA 侧 |
| `i2c/` | imx6↔FPGA 的寄存器桥 |
| `gps/`, `uart/`, `misc/`, `bd_cfg/`, `coef/`, `ip/` | 其余 |
约束在 `constrs_1/new/`:`smod_ioplanning.xdc`(SMOD 板的引脚,**这块板用这个**)、
`project_ioplanning.xdc`(旧板,别混用)、`timing.xdc`、`timing_cleanup_120m.xdc`、
`bitstream.xdc`、`debug.xdc`(当前为空,ILA 用 IP 例化而非 XDC 标记)。
## 用 vivado-mcp 干活
典型顺序(工具名即 `mcp__vivado-mcp__*`):
```
start_session(project=<xpr>) # 打开工程
→ verilog_compile_check # 改完 RTL 先做语法检查,比跑综合快得多
→ run_synthesis
→ get_critical_warnings # 综合后立刻看,别等到实现完
→ run_implementation
→ generate_bitstream
→ get_timing_report / get_utilization_report
→ power_off → USB_Blast_off → power_on → (等 sys init over) → USB_Blast_on
→ program_device # 下 bit(掉电即失)
```
其它常用:`run_tcl` / `safe_tcl`(任意 Tcl)、`get_run_progress`(长任务轮询)、
`get_ip_status` / `inspect_ip_params`、`xdc_lint` / `xdc_auto_fix`、`get_io_report`。
综合+实现在这个工程上要 **20~40 分钟**。改一行就重跑之前,先用 `verilog_compile_check`。
## ILA
`smod_top.v` 里 ILA 是**手工例化的 IP**(不是 `mark_debug`)。历史上用过 `ila_eth2`、
`ila_2`、`ila_3`、`ila_adrv9009_fb`;当前活跃的是 `u_ila_adrv9009_rx`(复用
`ila_adrv9009_fb` IP,探针宽度 16,16,1,8,1,1,1,8,8,8,8,5)。
要换探针就直接改那个例化的 probe 连接。**探针宽度必须和 IP 里配的一致**,否则综合报错。
需要新宽度就改 IP 或换一个 ILA IP。
抓波形要 `.ltx`(`impl_1/smod_top.ltx`)。归档时 `.bit` / `.ltx` / `.mcs` 三件套用**同一个 tag**
放进 `bitstream/`,否则下次想复现就对不上号了。ILA 导出的 CSV 交给 `/fft-analyse` 做频谱。
**固化前把调试 ILA 去掉**,重新实现出干净的生产 bit:ILA 吃资源、恶化时序,还会和 imx6
抢寄存器总线。
## 时序
`get_timing_report` 上 WNS 常年在 **-9 ~ -13 ns**,路径落在 JESD204 TX 的
`init_seq_data`(`jesd204_phy_1lane_38j82_refclk` group)。这是基础设计里**既有的
初始化序列 false path**,与 ATSC 数据通路无关 —— JESD 链路实测能起来,RF/调制/tuner 都正常。
不要为了这个数字去改设计。真正要盯的是你改动那部分的时钟域和 CDC。
## 出 mcs
走 `/fpga_update` skill。一句话:远程升级要的是 `_update.mcs`,不是 `write_cfgmem` 的直接产物;
而且它走 TCP 1234,不走网页。
+35
View File
@@ -0,0 +1,35 @@
# 历史笔记索引(`my_exciter\Notes\`)
约定:**`PRxx.md` = 需求**(用户提出的要做什么),**`Taskxx.md` = 方案 / 实现总结**
(怎么做的、踩了什么坑、验证结论)。`discussNN-*.md` 是过程讨论。
遇到相关主题**先读对应的 Task 文件**——里面的结论往往能省掉一整轮硬件迭代。
| 主题 | 需求 | 方案 / 总结 | 结论要点 |
|---|---|---|---|
| 从 EMV7.1 移植到 SMOD | `PR00.md`、`PR01.md` | `Task00.md`、`Task01.md` | 引脚重排到 `smod_ioplanning.xdc`;IO bank / 自由引脚勘查脚本也在 Notes 里(`task01_*.tcl`) |
| ADRV9009 JESD + SPI 寄存器核对 | `PR02.md` | `Task02.md` | JESD204B TX/RX lock status 寄存器位置 |
| TSoIP(网口 2 收 IP 流取 TS) | `PR03.md` | — | 根因见 memory `tsoip-root-cause-0x50` |
| Tuner(473 MHz RF → TS → 调制核) | `PR04.md` | `Task04.md` | demod 是 **Sony CXD2878 + FREIA**,不是 MN88436;移植 EMV9.3 的 `sonytuner/`;ILA 实测 19.41 Mbps、`ts_lock_a=1`、0x47 间隔 188 |
| ATSC 信号质量分析(RX1 路) | `PR06.md` | `Task06.md` | 用 RX1 接收自发信号,算带肩 + 8VSB 星座 + SNR,网页 `sys_spectrum.html` 显示 |
| 改用 ORx→DPD 40 MHz 反馈 | `PR06-1.md` | `Task06-1.md` | 反馈通道换成 ORx |
| ORx/RX1 双通道可切换 + DPD 内部 AGC | `PR06-2.md` | `Task06-2.md` | 共用滤波下采样;大量频谱截图见 `PR06-2_*.png`,索引在 `PR06-2_频谱截图索引.md` |
| 本 skill 的由来 | `PR07.md` | (本 skill) | 要求:SMOD ATSC 开发总入口,联调走 `/fpga-imx6-flow` |
## 其它散落资源
- `Notes/*.png` —— 历次频谱 / 星座 / 波形截图,文件名带 iter 号,配合 Task 文件看
- `Notes/create_ila_adrv9009_fb.tcl` —— 建 ILA IP 的 Tcl
- `Notes/task01_*.tcl` —— IO / DSP / 未约束端口的勘查脚本
- `Notes/workflow_state.db`、`workflow_threads.json` —— fpga-imx6-flow 的 LangGraph 状态
- `RU_golden/skills/*.py` —— 早期 RMII/GMII 抓包分析脚本(网口调试用)
- `bitstream/ila_*.csv`、`orx_*.csv` —— 历次 ILA 导出,交给 `/fft-analyse` 复现频谱
## 已固化的经验(别重新踩)
- 上电时序铁律;上电后等 `sys init over` 再连 JTAG,否则 TX 死。
- WNS -9~-13 ns 是 JESD204 `init_seq_data` 的既有 false path,与功能无关。
- imx6 dropbear 老:paramiko 2.12 + `cat >` 传文件(无 SFTP)。
- 设备上 `imx_atsc_app.rx1` 和 `M=2/lane 0x04` 变体会打断 TX;git `HEAD` 的版本是好的。
- 远程升级走 TCP 1234(不是网页!),只能传 `_update.mcs`,传错文件会导致冷启动后 FPGA 不跑。
- 远程升级"卡在擦除"通常是 programmer 已报错(rstate=0x7),imx6 轮询不看 ERROR 位。见 `/fpga_update`。
- 带调试 ILA 的 bit 下 `i2cget` 读 FPGA 寄存器不可靠。
+84
View File
@@ -0,0 +1,84 @@
# imx6 侧(atsc_app)
## 源码
`E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\imx6\src\atsc_app`(Makefile 在这一层):
| 目录 | 内容 |
|---|---|
| `main.c` | 起线程:`fpga_upgrade_task`、`tcp_task`、`web`、`lcd`、`gps`… |
| `bsp/` | 板级:`i2c_drv.c`、`fpga.c`(FPGA 寄存器读写)、`eeprom.c`(配置)、`dac38j82.c`、`dpd_proc.c`、`isys_config.c` |
| `talise/`, `adrv9009/`, `adi_hal/` | ADI ADRV9009 驱动 |
| `sonytuner/` | Sony CXD2878 + FREIA(CXD6821) 解调驱动(从 EMV9.3 移植,见 PR04) |
| `task/` | `upgrade.c`(软件包升级)、`tcp_task.c`、`iweb_task.c`、`tsoip.c`、`iq_rx.c` |
| `inc/` | 头文件;`fpga.h` 里 `FPGA_UPGRADE_I2C_REG_ADDRES = 0xE0` |
同级还有 `imx6/src/web/`(cgi + www 页面,含 `upload.cgi` 远程升级入口)。
## 交叉编译
工具链在 VM `bowei@192.168.223.128`(密码 `lr7402`):
```
/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi
```
流程(`scripts/imx6_app.py build` 已封装):
1. Windows 侧从 git 打包源码 —— **用 `HEAD` 而不是工作区**,这样构建的东西可追溯:
`git -C my_exciter archive --format=tar -o app.tar HEAD:imx6/src/atsc_app`(约 13 MB)
2. SFTP 上传到 VM,解到 **`~/atsc_head_build`**(一个干净目录;不要碰 `~/atsc_tsoip_build/` 之类的旧目录)
3. `bash -lc 'source <envsetup>; cd ~/atsc_head_build && make'` → 产出 `imx_atsc_app`(~2.9 MB ELF)
4. SFTP 取回 Windows
VM 的 sshd 是现代版本,paramiko 5 / OpenSSH 都能连。若报
`kex_exchange_identification: Connection closed`,是 VM 的 sshd 挂了,请用户 `sudo systemctl restart sshd`。
## 部署到设备
设备 `192.168.1.143`,`root` / `anywavecom`。**它的 SSH 是老 dropbear**,有两个坑:
1. **算法太老**:paramiko 5 会报 `unknown cipher` / `no acceptable host key`。
要用 **paramiko 2.12**(有 TripleDES / aes-cbc / ssh-rsa):
`python -m pip install --target <dir> paramiko==2.12.0`,然后 `PYTHONPATH=<dir>` 跑。
命令行 ssh 则加 `-o HostKeyAlgorithms=+ssh-rsa`。
2. **没有 SFTP 子系统**:`open_sftp()` 直接 `EOF during negotiation`。传文件走 exec channel:
```python
ch = client.get_transport().open_session()
ch.exec_command("cat > /aw/anywave/app/imx_atsc_app_head")
ch.sendall(open(local_bin, "rb").read())
ch.shutdown_write(); assert ch.recv_exit_status() == 0
```
(另一条老路是 SSH 反向端口转发 + 设备侧 `wget`,见 Task04;现在没必要了。)
部署顺序 —— **先落一个新名字,验证 md5,再覆盖 active**,这样传输失败不会毁掉能跑的 app:
```
cat > /aw/anywave/app/imx_atsc_app_head
chmod 755 imx_atsc_app_head
md5sum 对比
cp -a imx_atsc_app_head imx_atsc_app # 覆盖 active
```
`scripts/imx6_app.py deploy` 就是这个顺序。已知好用的备份是 `imx_atsc_app.bak_pre_rx1`。
改完 app 要**冷启动**(断电重启,遵守上电时序)让 `rc.local` 拉起新进程。
## 观察设备
- 日志:`/var/module_log/imx_atsc_app.log`。健康标志:`TALISE_radioOn`、`fb init state ok`、
`fpga[0xBB]=0x03`(sync=1 / tvalid=1)。`imx6_app.py log` 直接拉。
- 配置:`/aw/anywave/cfg/exc_cfg.conf`,`[BLK_SYS_PARA] reg50` 选输入源
—— `0`=Tuner,`1`=TS,`2`=EAS,`3`=TSoIP。
- 网页:`http://192.168.1.143`(`sys_rf.html`、`sys_ts.html`、`sys_set.html`、
`status_info.html`、`log_output.html`、以及远程升级的 `upload.cgi`)。
- 串口控制台(COM 号见 `~/fpga_imx6_flow.json` 的 `ports.imx6`,逐机而异、不入库)能用,但会自动登出、比较飘。**优先走网络 SSH**读日志和跑命令。
- 直接读 FPGA 寄存器可以 `i2cget`,但它会和 app 抢总线;带调试 ILA 的 bit 下更不可靠。
以 app 日志为准,或者临时停掉 app。
## 已知不能用的构建
设备上的 `imx_atsc_app.rx1`、以及任何 `M=2 / lane 0x04` 的变体**会打断 TX**。
git `HEAD` 的版本是 RX1-on-framerA(`M=4` / lane `0x03` / RX1RX2)+ TX 正常的已知好版本。
+24
View File
@@ -0,0 +1,24 @@
# 远程升级 / Flash 布局
**这块内容的唯一真源是 `fpga_update` skill。** 要生成 `_update.mcs` 或者把它烧进板子,
直接用那个 skill,别在这里另起炉灶。
- `~/.claude/skills/fpga_update/SKILL.md` —— 流程
- `~/.claude/skills/fpga_update/references/quickboot.md` —— flash 布局、切换字兜底、`-imagesize 64` 的由来
- `~/.claude/skills/fpga_update/references/protocol.md` —— TCP 1234 协议、以及 DPD 污染升级数据流的缺陷
三件容易记反的事:
1. **FPGA 的远程升级不走网页。** 网页的 Remote Update 只认 imx6 的 `*.tar.gz` 固件包;
`.mcs` 传上去会被 `check_package.sh` 直接删掉。FPGA 走 TCP **1234** 的私有协议
(原厂工具 `tools/Xfpga_ru.exe`,替代品 `fpga_update/scripts/fpga_ru.py`)。
2. **升级前必须关掉 DPD**,否则 DPD 的 SPI1 流量会混进镜像,CRC 校验失败。
3. **`_update.mcs` 不能 JTAG 烧写**,`_initial.mcs` 也不能远程升级。首次装机 / 救砖走
`/fpga_flash`(flash 型号 `w25q128bv`,烧 `RU_golden/bitstream/golden_design_initial.mcs`)。
## 调试期的第三条路
`program_device` 直接把 `.bit` 下到 FPGA,掉电即失。迭代验证用它最快 —— 不碰 flash,
配 `.ltx` 就能抓 ILA。功能确认后再走远程升级固化。
固化用的 bit 要**去掉调试 ILA 重新实现**:ILA 吃资源、恶化时序,还会和 imx6 抢寄存器总线。
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Cross-compile the SMOD imx6 atsc_app on the Ubuntu VM and deploy it to the board.
build package app source -> VM (~/atsc_head_build) -> make -> pull imx_atsc_app back
deploy push a local imx_atsc_app to 192.168.1.143, md5-verify, then swap it in
build-deploy both
log tail /var/module_log/imx_atsc_app.log from the device
Two device quirks drive the design here:
* The imx6 runs an old dropbear. Modern paramiko can't negotiate with it
("unknown cipher" / "no acceptable host key"), so this script vendors
paramiko 2.12 into ~/.cache/smod_atsc_dev and re-execs itself under it.
* That dropbear has no SFTP subsystem, so files go over an exec channel with
``cat > file`` rather than sftp.put().
Deploy writes imx_atsc_app_head first and only overwrites the active imx_atsc_app once
the md5 matches -- a half-transferred binary should never become the thing rc.local runs.
After deploying you still need a cold boot (power_off -> USB_Blast_off -> power_on ->
USB_Blast_on) for rc.local to pick up the new app. This script deliberately does not
touch the relays; that sequencing lives in the vivado-mcp tools.
"""
import argparse
import hashlib
import os
import subprocess
import sys
import tarfile
import tempfile
REPO = r"E:\Xilinx_design\smod\SMOD_ATSC\my_exciter"
APP_SUBDIR = "imx6/src/atsc_app"
VM_HOST, VM_USER, VM_PASS = "192.168.223.128", "bowei", "lr7402"
VM_BUILD_DIR = "atsc_head_build" # relative to the VM home dir
TOOLCHAIN = "/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi"
IMX_HOST, IMX_USER, IMX_PASS = "192.168.1.143", "root", "anywavecom"
IMX_APP_DIR = "/aw/anywave/app"
IMX_STAGING = f"{IMX_APP_DIR}/imx_atsc_app_head"
IMX_ACTIVE = f"{IMX_APP_DIR}/imx_atsc_app"
IMX_LOG = "/var/module_log/imx_atsc_app.log"
BINARY = "imx_atsc_app"
VENDOR_MARKER = "SMOD_PARAMIKO_VENDORED"
# paramiko 2.12 reaches for algorithms.TripleDES, which cryptography >= 43 removed.
VENDOR_PKGS = ["paramiko==2.12.0", "cryptography<43"]
# --------------------------------------------------------------------------- vendoring
def ensure_legacy_paramiko():
"""Re-exec under a vendored paramiko 2.12 unless we're already running under it."""
if os.environ.get(VENDOR_MARKER):
return
cache = os.path.join(os.path.expanduser("~"), ".cache", "smod_atsc_dev", "paramiko212")
if not os.path.isdir(os.path.join(cache, "paramiko")):
print(f"[setup] installing legacy paramiko into {cache} (one time)", flush=True)
os.makedirs(cache, exist_ok=True)
# --no-warn-conflicts: pip diffs --target installs against the ambient env and
# reports conflicts that don't apply here, which reads like a failure.
subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "--no-warn-conflicts",
"--target", cache] + VENDOR_PKGS, check=True)
env = dict(os.environ, **{VENDOR_MARKER: "1"})
env["PYTHONPATH"] = cache + os.pathsep + env.get("PYTHONPATH", "")
env["PYTHONWARNINGS"] = "ignore"
sys.exit(subprocess.run([sys.executable, os.path.abspath(__file__)] + sys.argv[1:], env=env).returncode)
# ------------------------------------------------------------------------------- ssh
def connect(host, user, password):
import paramiko
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(host, username=user, password=password, timeout=20, look_for_keys=False, allow_agent=False)
return cli
def run(cli, cmd, check=True, quiet=False):
if not quiet:
print(f" [ssh] {cmd}")
_, out, err = cli.exec_command(cmd, timeout=1800)
rc = out.channel.recv_exit_status()
stdout, stderr = out.read().decode(errors="replace"), err.read().decode(errors="replace")
if check and rc != 0:
sys.exit(f"[FAIL] rc={rc}\n{stdout}\n{stderr}")
return rc, stdout, stderr
def md5_of(path):
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# ----------------------------------------------------------------------------- build
def package_source(repo, source_mode):
tar_path = os.path.join(tempfile.mkdtemp(prefix="smod_app_"), "app.tar")
if source_mode == "git":
subprocess.run(["git", "-C", repo, "archive", "--format=tar", "-o", tar_path, f"HEAD:{APP_SUBDIR}"],
check=True)
rev = subprocess.run(["git", "-C", repo, "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, check=True).stdout.strip()
print(f"[build] packaged {APP_SUBDIR} from git HEAD ({rev})")
else:
src = os.path.join(repo, *APP_SUBDIR.split("/"))
skip = {".o", ".bak", ".zip", ".tar"}
with tarfile.open(tar_path, "w") as tf:
for root, _, files in os.walk(src):
for name in files:
if os.path.splitext(name)[1] in skip or name == BINARY:
continue
full = os.path.join(root, name)
tf.add(full, arcname=os.path.relpath(full, src))
print(f"[build] packaged {APP_SUBDIR} from the working tree (stale .o excluded)")
return tar_path
def cmd_build(args):
tar_path = package_source(args.repo, args.source)
out_path = os.path.abspath(args.out)
vm = connect(VM_HOST, VM_USER, VM_PASS)
try:
sftp = vm.open_sftp()
print(f"[build] uploading {os.path.getsize(tar_path)/1e6:.1f} MB to VM")
sftp.put(tar_path, f"/home/{VM_USER}/app_src.tar")
run(vm, f"rm -rf ~/{VM_BUILD_DIR} && mkdir -p ~/{VM_BUILD_DIR} && "
f"tar xf ~/app_src.tar -C ~/{VM_BUILD_DIR}")
print("[build] cross-compiling (this takes a few minutes)")
rc, out, err = run(vm, f"bash -lc 'source {TOOLCHAIN} && cd ~/{VM_BUILD_DIR} && make {args.make_args}'",
check=False)
tail = "\n".join((out + err).splitlines()[-40:])
if rc != 0:
sys.exit(f"[FAIL] make exited {rc}. Last output:\n{tail}")
print(tail)
remote_bin = f"/home/{VM_USER}/{VM_BUILD_DIR}/{BINARY}"
rc, _, _ = run(vm, f"test -f {remote_bin}", check=False)
if rc != 0:
sys.exit(f"[FAIL] make succeeded but {remote_bin} is missing")
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
sftp.get(remote_bin, out_path)
finally:
vm.close()
print(f"\n[OK] {out_path} ({os.path.getsize(out_path)/1e6:.2f} MB, md5 {md5_of(out_path)})")
return out_path
# ---------------------------------------------------------------------------- deploy
def push_file(cli, local, remote):
"""Send a file over an exec channel: the device's dropbear has no SFTP subsystem."""
stdin, stdout, _ = cli.exec_command(f"cat > {remote}", timeout=600)
with open(local, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
stdin.write(chunk)
stdin.channel.shutdown_write()
rc = stdout.channel.recv_exit_status()
if rc != 0:
sys.exit(f"[FAIL] `cat > {remote}` exited {rc}")
def cmd_deploy(args):
local = os.path.abspath(args.binary)
if not os.path.exists(local):
sys.exit(f"[FAIL] not found: {local}")
want = md5_of(local)
print(f"[deploy] {local} (md5 {want})")
imx = connect(IMX_HOST, IMX_USER, IMX_PASS)
try:
print(f"[deploy] staging -> {IMX_STAGING}")
push_file(imx, local, IMX_STAGING)
run(imx, f"chmod 755 {IMX_STAGING}")
_, out, _ = run(imx, f"md5sum {IMX_STAGING}")
got = out.split()[0]
if got != want:
sys.exit(f"[FAIL] md5 mismatch after transfer: device {got} != local {want}. "
f"Active app untouched.")
print("[deploy] md5 verified")
run(imx, f"cp -a {IMX_STAGING} {IMX_ACTIVE}")
_, out, _ = run(imx, f"md5sum {IMX_ACTIVE}")
if out.split()[0] != want:
sys.exit("[FAIL] active binary md5 mismatch after swap")
finally:
imx.close()
print(f"\n[OK] deployed to {IMX_ACTIVE}")
print("Now cold-boot the board so rc.local starts the new app:")
print(" vivado-mcp: power_off -> USB_Blast_off -> power_on -> (wait 'sys init over') -> USB_Blast_on")
print(f"Then check the log: python {os.path.basename(__file__)} log")
# ------------------------------------------------------------------------------- log
def cmd_log(args):
imx = connect(IMX_HOST, IMX_USER, IMX_PASS)
try:
cmd = f"tail -n {args.lines} {IMX_LOG}"
if args.grep:
cmd += f" | grep -E '{args.grep}'"
_, out, _ = run(imx, cmd, check=False, quiet=True)
print(out)
finally:
imx.close()
print("\nHealthy markers: TALISE_radioOn / 'fb init state ok' / fpga[0xBB]=0x03")
# ------------------------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
def add_build_args(p):
p.add_argument("--repo", default=REPO)
p.add_argument("--source", choices=["git", "worktree"], default="git",
help="git (default, reproducible) packages HEAD; worktree packages uncommitted edits")
p.add_argument("--make-args", default="", help="extra args passed to make on the VM")
p.add_argument("--out", default=os.path.join(tempfile.gettempdir(), BINARY))
b = sub.add_parser("build", help="cross-compile on the VM and pull the binary back")
add_build_args(b)
d = sub.add_parser("deploy", help="push a local binary to the imx6 and swap it in")
d.add_argument("binary", nargs="?", default=os.path.join(tempfile.gettempdir(), BINARY))
bd = sub.add_parser("build-deploy", help="build then deploy")
add_build_args(bd)
lg = sub.add_parser("log", help="tail the app log on the device")
lg.add_argument("-n", "--lines", type=int, default=80)
lg.add_argument("-g", "--grep", help="extended regex to filter with")
args = ap.parse_args()
ensure_legacy_paramiko() # after parsing, so --help never triggers a pip install
if args.cmd == "build":
cmd_build(args)
elif args.cmd == "deploy":
cmd_deploy(args)
elif args.cmd == "build-deploy":
args.binary = cmd_build(args)
cmd_deploy(args)
elif args.cmd == "log":
cmd_log(args)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: vivado-prj-create
description: DIRECT EXECUTION - Auto-create Vivado FPGA projects. Triggers: "create Vivado project", "Vivado工程", "FPGA project", "Xilinx project", "setup Vivado". When triggered, IMMEDIATELY execute Step 0 without listing skills or asking for confirmation.
description: 'DIRECT EXECUTION - Auto-create Vivado FPGA projects. Triggers: "create Vivado project", "Vivado工程", "FPGA project", "Xilinx project", "setup Vivado". When triggered, IMMEDIATELY execute Step 0 without listing skills or asking for confirmation.'
---
# Vivado Project TCL Generator