diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..0684f7b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Preserve supplier bytes/CRLF metadata while retaining real whitespace checks.
+web/fastui/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml
index cc5599e..58485dc 100644
--- a/.github/workflows/tag-ci.yml
+++ b/.github/workflows/tag-ci.yml
@@ -45,7 +45,7 @@ jobs:
deploy:
needs: validate
- if: startsWith(github.ref, 'refs/tags/v')
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2
with:
app: fastlabs
diff --git a/server.py b/server.py
index 8547075..ca6b299 100755
--- a/server.py
+++ b/server.py
@@ -3,6 +3,7 @@
import argparse
import json
+import math
import mimetypes
import os
import re
@@ -221,8 +222,8 @@ def parse_agent_timeout(value):
timeout = float(raw)
except (TypeError, ValueError) as exc:
raise ValueError("FASTLAB_AGENT_TIMEOUT 必须是大于等于 0 的秒数。") from exc
- if timeout < 0:
- raise ValueError("FASTLAB_AGENT_TIMEOUT 必须是大于等于 0 的秒数。")
+ if not math.isfinite(timeout) or timeout < 0:
+ raise ValueError("FASTLAB_AGENT_TIMEOUT 必须是有限且大于等于 0 的秒数。")
return timeout
@@ -2068,6 +2069,8 @@ def start_task(self, task_id):
def _start_task_locked(self, task_id):
task = self.require_task(task_id)
+ if task["cancel_requested"]:
+ raise ValueError("任务已请求取消。")
if task["status"] != "awaiting_approval":
raise ValueError("任务尚未准备好或已经开始。")
for subtask in task["subtasks"]:
@@ -2986,7 +2989,7 @@ def cancel_task(self, task_id):
def _cancel_task_locked(self, task_id):
task = self.require_task(task_id)
- if task["status"] not in {"planning", "running", "verifying"}:
+ if task["status"] not in {"planning", "awaiting_approval", "running", "verifying"}:
raise ValueError("当前任务不在运行。")
self.store.update_task(task_id, cancel_requested=1)
self.agent_gate.wake_all()
diff --git a/tests/test_fastlab.py b/tests/test_fastlab.py
index 8745206..01b572f 100644
--- a/tests/test_fastlab.py
+++ b/tests/test_fastlab.py
@@ -770,6 +770,35 @@ def test_cancel_stops_running_workers(self):
self.assertTrue(branch_exists(self.repo, "user-keep"))
self.assertFalse((self.data / "worktrees" / task["id"]).exists())
+ def test_cancel_awaiting_approval_does_not_start_work(self):
+ task = self.prepare_task()
+ finish_entered = threading.Event()
+ release_finish = threading.Event()
+ original_finish = self.app._finish_cancel
+
+ def blocked_finish(snapshot):
+ finish_entered.set()
+ self.assertTrue(release_finish.wait(5))
+ return original_finish(snapshot)
+
+ with patch.object(self.app, "_finish_cancel", side_effect=blocked_finish):
+ returned = self.app.cancel_task(task["id"])
+ self.assertEqual(returned["status"], "awaiting_approval")
+ self.assertTrue(returned["cancel_requested"])
+ self.assertTrue(finish_entered.wait(5))
+ with self.assertRaises(ValueError):
+ self.app.start_task(task["id"])
+ release_finish.set()
+
+ worker_calls = [
+ call for call in self.adapter.calls
+ if call["key"].startswith("subtask-%s-" % task["id"])
+ ]
+ self.assertEqual(worker_calls, [])
+ self.assertFalse((self.data / "worktrees" / task["id"]).exists())
+ cancelled = self.wait_for_status(task["id"], "cancelled")
+ self.assertTrue(all(item["status"] == "cancelled" for item in cancelled["subtasks"]))
+
def test_failure_conflict_and_retry_preserve_main(self):
task = self.prepare_task("CONFLICT")
base = git(self.repo, "rev-parse", "main")
@@ -1267,6 +1296,9 @@ def test_command_helpers_support_windows(self):
self.assertEqual(parse_agent_timeout("12.5"), 12.5)
with self.assertRaisesRegex(ValueError, "大于等于 0"):
parse_agent_timeout("-1")
+ for raw in ("nan", "NaN", "inf", "Infinity", "1e9999"):
+ with self.subTest(raw=raw), self.assertRaisesRegex(ValueError, "有限"):
+ parse_agent_timeout(raw)
def test_cycle_and_unknown_executor_are_rejected(self):
cycle = {
diff --git a/web/FASTUI.md b/web/FASTUI.md
new file mode 100644
index 0000000..f4730db
--- /dev/null
+++ b/web/FASTUI.md
@@ -0,0 +1,5 @@
+# FastUI 0.1.2
+
+部署前运行 `python web/supply_fastui.py`。离线供应:`python web/supply_fastui.py --artifact /path/to/ui-foundation-0.1.2.tgz`。脚本从固定官方 npm tarball URL 下载或读取同字节 tarball,校验 SHA-256 后生成 `web/fastui/`,含版本、包摘要、逐文件摘要和许可证;供应目录不手工编辑。升级时从公开 registry metadata 确认实际 URL,并下载核准版本和完整摘要。
+
+提交/部署生成目录后,服务运行只读取本地文件,不需要 npm/CDN。颜色模式初始化在 HTML 内容之前执行,`fastlabs-theme` 未配置时跟随系统;共享控件通过显式 `fastui-*` 类 opt-in,宿主业务布局使用共享 token。任务表单字段、事件委托和调度状态不变。
diff --git a/web/app.js b/web/app.js
index 027f41a..e3bc426 100644
--- a/web/app.js
+++ b/web/app.js
@@ -200,14 +200,14 @@ function renderCreate() {
main.innerHTML = `
`;
}
diff --git a/web/fastui/LICENSE b/web/fastui/LICENSE
new file mode 100644
index 0000000..47c4d66
--- /dev/null
+++ b/web/fastui/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 FastR-D contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/web/fastui/NOTICE.md b/web/fastui/NOTICE.md
new file mode 100644
index 0000000..59d1ef6
--- /dev/null
+++ b/web/fastui/NOTICE.md
@@ -0,0 +1,23 @@
+# Source and publication scope
+
+The owner has authorized publication of the generic UI portions under MIT.
+Vue semantic primitives and v-model adapters derive from the common
+`primitives.ts` (203 lines) in FastPPT and FastFigure. Modal's public props and
+the focus-lifecycle purpose derive from their common `use-dialog-focus.ts`
+(58 lines) and Modal component. FastUI delegates focus trapping, inertness,
+nesting and focus restoration to the browser's native modal dialog rather
+than maintaining the original hand-written trap. Select retains the shared
+value/options/change contract and delegates interaction to the native select.
+
+The palette follows FastWrite's semantic colors and the approved FastTask
+workbench design. Only generic UI code and semantic design values are included.
+No private artwork, icons, logos, fonts, application data, product-specific
+workflows or brand assets are distributed. Upstream product repositories
+retain ownership of their application-specific code and assets.
+
+Source reference revisions: FastPPT `f6bf21f13445c2507719928fa68550552a8fcab2`
+(`packages/components/src/`) and FastFigure
+`13dcc4179fd0073da76f7626264af8605d5e8c70` (`src/components/ui/`).
+At both revisions, `primitives.ts` has Git blob
+`c7123af5f8316bc0486bce5c99cfbb2e9ab3dc73` and `use-dialog-focus.ts` has
+Git blob `3ffbb6c6c7bd5e3c96f2b8c05c5e008dcdfc4cab`.
diff --git a/web/fastui/color-mode.js b/web/fastui/color-mode.js
new file mode 100644
index 0000000..02a627b
--- /dev/null
+++ b/web/fastui/color-mode.js
@@ -0,0 +1,75 @@
+"use strict";
+(() => {
+ // packages/foundation/src/index.ts
+ var parse = (value) => value === "light" || value === "dark" ? value : "system";
+ function initColorMode({ storageKey: storageKey2, target = document.documentElement }) {
+ const win = target.ownerDocument.defaultView;
+ const media = win.matchMedia("(prefers-color-scheme: dark)");
+ let mode = "system";
+ try {
+ mode = parse(win.localStorage.getItem(storageKey2));
+ } catch {
+ }
+ let disposed = false;
+ const listeners = /* @__PURE__ */ new Set();
+ const resolved = () => mode === "system" ? media.matches ? "dark" : "light" : mode;
+ const apply = () => {
+ target.dataset.fastuiTheme = resolved();
+ listeners.forEach((listener) => listener({ mode, resolved: resolved() }));
+ };
+ const systemChange = () => {
+ if (mode === "system") apply();
+ };
+ const storageChange = (event) => {
+ let storage;
+ try {
+ storage = win.localStorage;
+ } catch {
+ return;
+ }
+ if (event.storageArea !== storage || event.key !== storageKey2 && event.key !== null) return;
+ mode = parse(event.newValue);
+ apply();
+ };
+ media.addEventListener("change", systemChange);
+ win.addEventListener("storage", storageChange);
+ apply();
+ return {
+ get mode() {
+ return mode;
+ },
+ get resolved() {
+ return resolved();
+ },
+ setMode(value) {
+ if (disposed) return;
+ mode = parse(value);
+ try {
+ win.localStorage.setItem(storageKey2, mode);
+ } catch {
+ }
+ apply();
+ },
+ subscribe(listener) {
+ if (disposed) return () => {
+ };
+ listeners.add(listener);
+ listener({ mode, resolved: resolved() });
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ dispose() {
+ disposed = true;
+ media.removeEventListener("change", systemChange);
+ win.removeEventListener("storage", storageChange);
+ listeners.clear();
+ }
+ };
+ }
+
+ // packages/foundation/src/bootstrap.ts
+ var storageKey = document.currentScript.dataset.storageKey;
+ if (!storageKey) throw new Error("FastUI color-mode.js requires data-storage-key on its script element.");
+ initColorMode({ storageKey });
+})();
diff --git a/web/fastui/controls.css b/web/fastui/controls.css
new file mode 100644
index 0000000..fd85a23
--- /dev/null
+++ b/web/fastui/controls.css
@@ -0,0 +1,61 @@
+/* Every appearance rule is explicitly opted into by a fastui class. No reset. */
+.fastui-button, .fastui-input, .fastui-textarea, .fastui-select {
+ box-sizing: border-box;
+ font-family: var(--fastui-font);
+ font-size: 13px;
+ line-height: 1.5;
+ min-height: var(--fastui-height-md);
+ padding: 7px 10px;
+ border: 1px solid var(--fastui-border-strong);
+ border-radius: var(--fastui-radius-control);
+ background: var(--fastui-surface);
+ color: var(--fastui-text);
+ max-width: 100%;
+ min-width: 0;
+ color-scheme: var(--fastui-color-scheme);
+}
+.fastui-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; font-weight: 500; text-decoration: none; }
+.fastui-button--sm, .fastui-input--sm, .fastui-select--sm { min-height: var(--fastui-height-sm); padding: 4px 8px; font-size: 12px; }
+.fastui-button--xs, .fastui-input--xs, .fastui-select--xs { min-height: var(--fastui-height-xs); padding: 1px 6px; font-size: 12px; }
+.fastui-button:hover:not(:disabled) { background: var(--fastui-selected); }
+.fastui-button--primary { background: var(--fastui-primary); border-color: var(--fastui-primary); color: var(--fastui-on-primary); }
+.fastui-button--primary:hover:not(:disabled) { background: var(--fastui-primary-hover); }
+.fastui-button--danger { background: var(--fastui-danger-fill); border-color: var(--fastui-danger-fill); color: var(--fastui-on-primary); }
+.fastui-button--danger:hover:not(:disabled) { filter: brightness(.9); background: var(--fastui-danger-fill); }
+.fastui-button--quiet, .fastui-button--icon, .fastui-button--navigation { background: transparent; border-color: transparent; }
+.fastui-button--icon { aspect-ratio: 1; padding: 5px; }
+.fastui-button--navigation { border-radius: 0; }
+.fastui-button[aria-pressed=true], .fastui-button[aria-selected=true] { box-shadow: inset 0 -2px var(--fastui-focus); }
+.fastui-button:disabled, .fastui-input:disabled, .fastui-textarea:disabled, .fastui-select:disabled { opacity: .6; cursor: not-allowed; }
+.fastui-button:focus-visible, .fastui-input:focus-visible, .fastui-textarea:focus-visible, .fastui-select:focus-visible, .fastui-link:focus-visible, .fastui-disclosure-trigger:focus-visible { outline: 2px solid var(--fastui-focus); outline-offset: 2px; }
+.fastui-input::placeholder, .fastui-textarea::placeholder { color: var(--fastui-muted); }
+.fastui-input[aria-invalid=true], .fastui-textarea[aria-invalid=true] { border-color: var(--fastui-danger); }
+.fastui-input[type=checkbox], .fastui-input[type=radio], .fastui-input[type=range] { min-height: auto; padding: 0; accent-color: var(--fastui-primary); }
+.fastui-textarea { resize: vertical; }
+.fastui-field { display: grid; gap: 6px; color: var(--fastui-secondary); font: 13px/1.5 var(--fastui-font); }
+.fastui-fieldset { border: 1px solid var(--fastui-border); border-radius: var(--fastui-radius-panel); min-width: 0; padding: 16px; }
+.fastui-legend { color: var(--fastui-secondary); padding: 0 4px; }
+.fastui-heading { color: var(--fastui-text); font-weight: 600; }
+.fastui-link { color: var(--fastui-focus); text-underline-offset: 3px; }
+.fastui-progress { accent-color: var(--fastui-primary); }
+.fastui-table { border-collapse: collapse; text-align: left; }
+.fastui-table :where(th, td) { padding: 12px; border-bottom: 1px solid var(--fastui-border); }
+.fastui-table th { background: var(--fastui-raised); font-weight: 600; }
+.fastui-disclosure-trigger { padding: 5px 8px; cursor: pointer; }
+.fastui-dialog { box-sizing: border-box; width: min(620px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 0; border: 1px solid var(--fastui-border); border-radius: var(--fastui-radius-panel); background: var(--fastui-surface); color: var(--fastui-text); font: 14px/1.5 var(--fastui-font); box-shadow: 0 16px 48px #0004; }
+.fastui-dialog--lg { width: min(880px, calc(100% - 32px)); }
+.fastui-dialog--xl { width: min(1100px, calc(100% - 32px)); }
+.fastui-dialog::backdrop { background: var(--fastui-overlay); }
+.fastui-dialog > :where(header, footer), .fastui-modal-body { padding: 20px 24px; }
+.fastui-dialog > header { border-bottom: 1px solid var(--fastui-border); }
+.fastui-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--fastui-border); }
+.fastui-modal-body { display: grid; gap: 16px; }
+.fastui-alert, .fastui-badge, .fastui-empty, .fastui-loading { font-family: var(--fastui-font); color: var(--fastui-text); }
+.fastui-alert { border: 1px solid var(--fastui-border); border-left: 3px solid var(--fastui-tone, var(--fastui-focus)); border-radius: var(--fastui-radius-control); padding: 12px 16px; background: var(--fastui-surface); }
+.fastui-badge { display: inline-block; border: 1px solid currentColor; color: var(--fastui-tone, var(--fastui-focus)); border-radius: 4px; padding: 1px 6px; font-size: 12px; }
+.fastui-tone-success { --fastui-tone: var(--fastui-success); }
+.fastui-tone-warning { --fastui-tone: var(--fastui-warning); }
+.fastui-tone-danger { --fastui-tone: var(--fastui-danger); }
+.fastui-empty { padding: 24px; text-align: center; }
+.fastui-empty > h2 { font-size: 16px; }
+.fastui-loading { display: inline-flex; align-items: center; gap: 8px; }
diff --git a/web/fastui/provenance.json b/web/fastui/provenance.json
new file mode 100644
index 0000000..3ae746f
--- /dev/null
+++ b/web/fastui/provenance.json
@@ -0,0 +1,12 @@
+{
+ "package": "@fastrd/ui-foundation",
+ "version": "0.1.2",
+ "url": "https://registry.npmjs.org/@fastrd/ui-foundation/-/ui-foundation-0.1.2.tgz",
+ "sha256": "c3dabbad16980242f66db07619b161889d0ee2492daaa4aaddee1583bc7bcd0b",
+ "files": {
+ "tokens.css": "fadf2cf99aa54150ff65776032c4f61b007ebe47a0051723f81afac0b397de38",
+ "controls.css": "f34fa4d48fbae9dcbf22fc7fcb1718e2b1a5cb33c36bc287805fd49b61cecbc4",
+ "color-mode.js": "596e123cf27bf280dec4cabafe98dfbcf780fbef546763c8c27a0c28e6c52f99"
+ },
+ "generated_by": "python web/supply_fastui.py"
+}
diff --git a/web/fastui/tokens.css b/web/fastui/tokens.css
new file mode 100644
index 0000000..7008b75
--- /dev/null
+++ b/web/fastui/tokens.css
@@ -0,0 +1,98 @@
+/* Generated from tokens.json. */
+:root {
+ --fastui-font: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans CJK SC', sans-serif;
+ --fastui-font-size: 14px;
+ --fastui-radius-control: 6px;
+ --fastui-radius-panel: 8px;
+ --fastui-space-1: 4px;
+ --fastui-space-2: 8px;
+ --fastui-space-3: 12px;
+ --fastui-space-4: 16px;
+ --fastui-space-6: 24px;
+ --fastui-space-8: 32px;
+ --fastui-height-xs: 24px;
+ --fastui-height-sm: 30px;
+ --fastui-height-md: 38px;
+ --fastui-on-primary: #ffffff;
+ --fastui-on-primary-rgb: 255, 255, 255;
+}
+:root, [data-fastui-theme="light"] {
+ --fastui-page: #f7f8fa;
+ --fastui-page-rgb: 247, 248, 250;
+ --fastui-workspace: #eef0f3;
+ --fastui-workspace-rgb: 238, 240, 243;
+ --fastui-surface: #ffffff;
+ --fastui-surface-rgb: 255, 255, 255;
+ --fastui-raised: #eef0f3;
+ --fastui-raised-rgb: 238, 240, 243;
+ --fastui-text: #151c28;
+ --fastui-text-rgb: 21, 28, 40;
+ --fastui-secondary: #4b5565;
+ --fastui-secondary-rgb: 75, 85, 101;
+ --fastui-muted: #687384;
+ --fastui-muted-rgb: 104, 115, 132;
+ --fastui-border: #dfe3e8;
+ --fastui-border-rgb: 223, 227, 232;
+ --fastui-border-strong: #a7b0bd;
+ --fastui-border-strong-rgb: 167, 176, 189;
+ --fastui-primary: #155fb8;
+ --fastui-primary-rgb: 21, 95, 184;
+ --fastui-primary-hover: #104a91;
+ --fastui-primary-hover-rgb: 16, 74, 145;
+ --fastui-focus: #155fb8;
+ --fastui-focus-rgb: 21, 95, 184;
+ --fastui-selected: #e5effb;
+ --fastui-selected-rgb: 229, 239, 251;
+ --fastui-disabled: #e8ebef;
+ --fastui-disabled-rgb: 232, 235, 239;
+ --fastui-success: #16834a;
+ --fastui-success-rgb: 22, 131, 74;
+ --fastui-warning: #8c5b09;
+ --fastui-warning-rgb: 140, 91, 9;
+ --fastui-danger: #c93636;
+ --fastui-danger-rgb: 201, 54, 54;
+ --fastui-danger-fill: #c93636;
+ --fastui-danger-fill-rgb: 201, 54, 54;
+ --fastui-overlay: #151c2880;
+ --fastui-color-scheme: light;
+}
+[data-fastui-theme="dark"] {
+ --fastui-page: #111820;
+ --fastui-page-rgb: 17, 24, 32;
+ --fastui-workspace: #111820;
+ --fastui-workspace-rgb: 17, 24, 32;
+ --fastui-surface: #171e27;
+ --fastui-surface-rgb: 23, 30, 39;
+ --fastui-raised: #273342;
+ --fastui-raised-rgb: 39, 51, 66;
+ --fastui-text: #f4f7fa;
+ --fastui-text-rgb: 244, 247, 250;
+ --fastui-secondary: #c5cfdb;
+ --fastui-secondary-rgb: 197, 207, 219;
+ --fastui-muted: #9aa8b9;
+ --fastui-muted-rgb: 154, 168, 185;
+ --fastui-border: #354252;
+ --fastui-border-rgb: 53, 66, 82;
+ --fastui-border-strong: #65758a;
+ --fastui-border-strong-rgb: 101, 117, 138;
+ --fastui-primary: #0e639c;
+ --fastui-primary-rgb: 14, 99, 156;
+ --fastui-primary-hover: #1177bb;
+ --fastui-primary-hover-rgb: 17, 119, 187;
+ --fastui-focus: #5ba3ea;
+ --fastui-focus-rgb: 91, 163, 234;
+ --fastui-selected: #094771;
+ --fastui-selected-rgb: 9, 71, 113;
+ --fastui-disabled: #273342;
+ --fastui-disabled-rgb: 39, 51, 66;
+ --fastui-success: #6acc96;
+ --fastui-success-rgb: 106, 204, 150;
+ --fastui-warning: #e6ba6a;
+ --fastui-warning-rgb: 230, 186, 106;
+ --fastui-danger: #ff848c;
+ --fastui-danger-rgb: 255, 132, 140;
+ --fastui-danger-fill: #b03030;
+ --fastui-danger-fill-rgb: 176, 48, 48;
+ --fastui-overlay: #00000099;
+ --fastui-color-scheme: dark;
+}
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
index fb94955..78f62dc 100644
--- a/web/index.html
+++ b/web/index.html
@@ -6,6 +6,9 @@
FastLab
+
+
+
@@ -21,7 +24,7 @@ FastLab
-