create-aiaui-module 0.0.0-stage → 0.1.1-TEST
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -2
- package/bin/create-aiaui-module.js +137 -0
- package/package.json +30 -4
- package/runtime/__init__.py +82 -0
- package/runtime/_runtime/__init__.py +40 -0
- package/runtime/_runtime/host.py +88 -0
- package/runtime/_runtime/loader.py +39 -0
- package/runtime/_runtime/registry.py +93 -0
- package/src/errors.js +6 -0
- package/src/generator.js +194 -0
- package/src/protocol.js +98 -0
- package/templates/HelloWorld/README.md +13 -0
- package/templates/HelloWorld/contractVersion +1 -0
- package/templates/HelloWorld/resources/build.mjs +9 -0
- package/templates/HelloWorld/resources/dist/manifest.json +6 -0
- package/templates/HelloWorld/resources/dist/modules.js +3753 -0
- package/templates/HelloWorld/resources/package.json +12 -0
- package/templates/HelloWorld/resources/src/Hello.ce.vue +25 -0
- package/templates/HelloWorld/resources/src/index.ts +32 -0
- package/templates/HelloWorld/resources/vite.config.mjs +13 -0
- package/templates/JobInformation/CUSTOMIZATION.md +173 -0
- package/templates/JobInformation/README.md +114 -0
- package/templates/JobInformation/api.py +53 -0
- package/templates/JobInformation/contractVersion +1 -0
- package/templates/JobInformation/resources/build.mjs +9 -0
- package/templates/JobInformation/resources/dist/manifest.json +6 -0
- package/templates/JobInformation/resources/dist/modules.js +2919 -0
- package/templates/JobInformation/resources/package-lock.json +1032 -0
- package/templates/JobInformation/resources/package.json +15 -0
- package/templates/JobInformation/resources/src/bus.ts +38 -0
- package/templates/JobInformation/resources/src/contract.ts +74 -0
- package/templates/JobInformation/resources/src/customization.ts +25 -0
- package/templates/JobInformation/resources/src/defaults/DefaultPanel.vue +22 -0
- package/templates/JobInformation/resources/src/defaults/Header.vue +12 -0
- package/templates/JobInformation/resources/src/defaults/InputCarriers.vue +15 -0
- package/templates/JobInformation/resources/src/defaults/OutputCarriers.vue +14 -0
- package/templates/JobInformation/resources/src/defaults/Recipe.vue +18 -0
- package/templates/JobInformation/resources/src/defaults/Timeline.vue +22 -0
- package/templates/JobInformation/resources/src/defaults/slots.ts +16 -0
- package/templates/JobInformation/resources/src/defaults/styles.css +90 -0
- package/templates/JobInformation/resources/src/index.ts +45 -0
- package/templates/JobInformation/resources/src/vite-env.d.ts +1 -0
- package/templates/JobInformation/resources/vite.config.mjs +27 -0
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-aiaui-module
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 AIAUI Instance 根目录一键生成可编辑的模块目录(FastAPI 后端路由 + Vue 前端)。
|
|
4
|
+
通过 `npx` 运行,无需全局安装、无需克隆仓库,运行时无外部依赖(除 Instance
|
|
5
|
+
侧已声明的 Python 依赖)。
|
|
6
|
+
|
|
7
|
+
| 步骤 | 命令 | 说明 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| 查看可用模块 | `npx create-aiaui-module@latest --list` | 列出模块 ID 与 contractVersion |
|
|
10
|
+
| 生成模块 | `npx create-aiaui-module@latest JobInformation` | 在实例根目录生成 `PyAIAUI/JobInformation` |
|
|
11
|
+
| 机器可读 | `npx create-aiaui-module@latest JobInformation --json` | 生成结果输出 JSON(接 CI) |
|
|
12
|
+
|
|
13
|
+
## 环境要求
|
|
14
|
+
|
|
15
|
+
- 执行生成器的机器:Node.js >= 20
|
|
16
|
+
- Instance 运行侧:Python + `fastapi`、`uvicorn`(Instance 侧自备,`pip install fastapi uvicorn`)
|
|
17
|
+
|
|
18
|
+
## 快速开始
|
|
19
|
+
|
|
20
|
+
在 Instance 根目录(含 `Config/` 的目录)下执行:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx create-aiaui-module@latest JobInformation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
生成后 `PyAIAUI/JobInformation/` 与 `PyAIAUI/_runtime/` 就位,`Config/AIAUIProtocol.json` 中登记该模块(默认 `enabled: false`、`port: null`)。
|
|
27
|
+
|
|
28
|
+
启用前需要调整的配置:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"schemaVersion": 1,
|
|
33
|
+
"port": 18901,
|
|
34
|
+
"modules": [
|
|
35
|
+
{ "name": "JobInformation", "enabled": true }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
把 Instance 启动/关闭流程接上(生成后 `PyAIAUI` 目录里就是这段代码的宿主实现):
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from PyAIAUI import AIAUI
|
|
44
|
+
|
|
45
|
+
# 业务对象就绪后
|
|
46
|
+
aiaui = AIAUI.start(services={"job_repository": repo})
|
|
47
|
+
# aiaui 为 None 表示没有任何模块启用
|
|
48
|
+
|
|
49
|
+
# ... Instance 正常运行 ...
|
|
50
|
+
|
|
51
|
+
# 关闭前
|
|
52
|
+
if aiaui is not None:
|
|
53
|
+
aiaui.stop()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`AIAUI.start()` 幂等(重复调用返回同一 handle),一个 handle 托管本 Instance 全部启用模块,共用一个 loopback 端口。后端接口由模块自己的 `api.py` 里的 `create_api(context)` 提供,`context.services` 就是上面注入的业务对象。
|
|
57
|
+
|
|
58
|
+
## 生成结果
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
<Instance root>/
|
|
62
|
+
├── Config/AIAUIProtocol.json # 运行协议:端口 + 模块启停(生成器只登记,不配置)
|
|
63
|
+
└── PyAIAUI/
|
|
64
|
+
├── __init__.py # 入口:AIAUI 类(AIAUI.start / aiaui.stop)
|
|
65
|
+
├── JobInformation/ # 模块目录,目录名 == 模块 ID
|
|
66
|
+
│ ├── api.py # 后端契约:create_api(context) -> APIRouter
|
|
67
|
+
│ ├── resources/src/ # Vue 前端源码
|
|
68
|
+
│ ├── resources/dist/ # 预构建产物(开箱即跑,改源码后 npm run build 重出)
|
|
69
|
+
│ └── {README.md, CUSTOMIZATION.md}# 模块说明与定制手册
|
|
70
|
+
└── _runtime/ # 内嵌模块宿主(零外部依赖)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
生成器不选端口、不装依赖、不启动进程、不覆盖已存在的模块目录;无效的既有协议会被拒绝且不留残留。
|
|
74
|
+
|
|
75
|
+
## 内置模块清单
|
|
76
|
+
|
|
77
|
+
| 模块 ID | 说明 | 组成 |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `JobInformation` | 工单 / 载具 / 工序信息面板 | FastAPI 后端 + Vue 前端;内置 `header`、`inputCarriers`、`outputCarriers`、`timeline`、`recipe` 五个可替换槽位;三级定制(默认 / 局部替换 / 全量自定义) |
|
|
80
|
+
| `HelloWorld` | 最小前端模块示例 | 自定义元素;验证挂载链路,无后端 |
|
|
81
|
+
自定义通过 `resources/src/customization.ts` 完成,槽位通信通过 `context.bus`(事件 / expose+peer),详见生成目录里的 `CUSTOMIZATION.md`。
|
|
82
|
+
|
|
83
|
+
## UIService 集成
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm i -g create-aiaui-module
|
|
87
|
+
export AIA_UI_MODULE_TEMPLATE_ROOT="$(npm root -g)/create-aiaui-module"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
此后 UIService 调用包内 `bin/create-aiaui-module.js`(与 `npx` 行为一致),或在业务机上直接 `npx`。
|
|
91
|
+
|
|
92
|
+
## 常用命令
|
|
93
|
+
|
|
94
|
+
- `npx create-aiaui-module@latest --list` — 查看模板
|
|
95
|
+
- `npx create-aiaui-module@latest <ModuleId>` — 生成(`--instance-root` 指定根目录)
|
|
96
|
+
- `npx create-aiaui-module@latest --json --list` — 机器可读
|
|
97
|
+
|
|
98
|
+
## 维护者(发布新模板)
|
|
99
|
+
|
|
100
|
+
- 新增模块:在 `templates/<ModuleId>/` 下放实现(含 `contractVersion`、`README.md`、`resources/`)
|
|
101
|
+
- 前端模板发布前在 `resources/` 跑 `npm install && npm run build`,把产物一起随包发布;发布自动执行 `verify-dist` 校验每个模板的 `contractVersion`、入口、`dist/` 产物
|
|
102
|
+
- 发版流程:`npm test` → 改 `package.json` 版本号 → `npm publish`
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createModule,
|
|
8
|
+
GeneratorError,
|
|
9
|
+
listTemplates,
|
|
10
|
+
} from "../src/generator.js";
|
|
11
|
+
|
|
12
|
+
const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
const DIVIDER = "─".repeat(48);
|
|
14
|
+
|
|
15
|
+
function briefDescription(templateId) {
|
|
16
|
+
try {
|
|
17
|
+
const text = readFileSync(
|
|
18
|
+
path.join(packageRoot, "templates", templateId, "README.md"),
|
|
19
|
+
"utf8",
|
|
20
|
+
);
|
|
21
|
+
for (const line of text.split("\n")) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("---"))
|
|
24
|
+
continue;
|
|
25
|
+
// Keep the first sentence so the list stays scannable.
|
|
26
|
+
const sentence = trimmed.split(/(?<=\.)\s([A-Z])/)[0];
|
|
27
|
+
const clipped = sentence.length > 120 ? `${sentence.slice(0, 117)}…` : sentence;
|
|
28
|
+
return clipped.length > 0 ? clipped : trimmed;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* template without README simply shows no description */
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function printTemplateList(entries) {
|
|
37
|
+
console.log();
|
|
38
|
+
console.log("AIAUI 模块模板");
|
|
39
|
+
console.log(DIVIDER);
|
|
40
|
+
if (entries.length === 0) console.log(" (none)");
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
console.log(` ${entry.id} · contract v${entry.contractVersion}`);
|
|
43
|
+
if (briefDescription(entry.id))
|
|
44
|
+
console.log(` ${briefDescription(entry.id)}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(DIVIDER);
|
|
47
|
+
console.log("生成: npx create-aiaui-module <模块名> [--list] [--json]");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printSuccess(result) {
|
|
51
|
+
console.log();
|
|
52
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
53
|
+
console.log(" [AIAUI] 生成成功");
|
|
54
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(` ✅ 生成路径 ${result.path} (${result.filesCopied} 个文件)`);
|
|
57
|
+
console.log(` ✅ 协议配置 ${result.configPath} (enabled: ${result.enabled})`);
|
|
58
|
+
console.log();
|
|
59
|
+
console.log("⚠️ 配置调整(需要你手动做)");
|
|
60
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
61
|
+
console.log(" 📌 修改 port 字段为 18901(或你选择的其他空闲端口)");
|
|
62
|
+
console.log(" 📌 将 enabled 改为 true");
|
|
63
|
+
console.log(" 📌 模块接入 Instance 启动流程后再启用");
|
|
64
|
+
console.log();
|
|
65
|
+
console.log("🔧 接下来要做:");
|
|
66
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
67
|
+
console.log(" 1. 配置端口");
|
|
68
|
+
console.log(" 修改 AIAUIProtocol.json 中 port 字段为 18901(示例值)");
|
|
69
|
+
console.log(" 2. 配置模块 enable");
|
|
70
|
+
console.log(" 修改 AIAUIProtocol.json 中对应模块的 enabled 为 true");
|
|
71
|
+
console.log(" 3. 接入 Instance 启动流程");
|
|
72
|
+
console.log(" · 启动时: aiaui = AIAUI.start(services={...})");
|
|
73
|
+
console.log(" · 关闭时: aiaui.stop()");
|
|
74
|
+
console.log(" 4. 打开 UIClient 对应页面查看效果(JobInformation → /jobs)");
|
|
75
|
+
console.log();
|
|
76
|
+
console.log("❌ 没有做的(请确认)");
|
|
77
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
78
|
+
console.log(" · 未启动服务(由 Instance 生命周期调用 AIAUI.start)");
|
|
79
|
+
console.log(" · 未安装依赖(模块运行需要 pip install fastapi uvicorn)");
|
|
80
|
+
console.log(" · 未选择/配置端口");
|
|
81
|
+
console.log(" · 未手动启用任何模块");
|
|
82
|
+
console.log();
|
|
83
|
+
console.log(" 初始化目录已就绪,按上面的顺序操作即可上线。");
|
|
84
|
+
console.log();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArgs(args) {
|
|
88
|
+
const options = {
|
|
89
|
+
json: false,
|
|
90
|
+
list: false,
|
|
91
|
+
instanceRoot: process.cwd(),
|
|
92
|
+
templateId: null,
|
|
93
|
+
};
|
|
94
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
95
|
+
const arg = args[index];
|
|
96
|
+
if (arg === "--json") options.json = true;
|
|
97
|
+
else if (arg === "--list") options.list = true;
|
|
98
|
+
else if (arg === "--instance-root" && args[index + 1])
|
|
99
|
+
options.instanceRoot = args[++index];
|
|
100
|
+
else if (!arg.startsWith("-") && options.templateId === null)
|
|
101
|
+
options.templateId = arg;
|
|
102
|
+
else
|
|
103
|
+
throw new GeneratorError(
|
|
104
|
+
"invalid_arguments",
|
|
105
|
+
`Unexpected argument: ${arg}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!options.list && !options.templateId) {
|
|
109
|
+
throw new GeneratorError(
|
|
110
|
+
"invalid_arguments",
|
|
111
|
+
"Usage: create-aiaui-module <ModuleId> [--instance-root <path>] [--json]",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return options;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const options = parseArgs(process.argv.slice(2));
|
|
119
|
+
if (options.list) {
|
|
120
|
+
const entries = await listTemplates(packageRoot);
|
|
121
|
+
if (options.json) console.log(JSON.stringify(entries));
|
|
122
|
+
else printTemplateList(entries);
|
|
123
|
+
} else {
|
|
124
|
+
const result = await createModule({
|
|
125
|
+
templateId: options.templateId,
|
|
126
|
+
instanceRoot: options.instanceRoot,
|
|
127
|
+
root: packageRoot,
|
|
128
|
+
});
|
|
129
|
+
if (options.json) console.log(JSON.stringify(result));
|
|
130
|
+
else printSuccess(result);
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const code =
|
|
134
|
+
error instanceof GeneratorError ? error.code : "generator_failed";
|
|
135
|
+
console.error(JSON.stringify({ code, message: error.message }));
|
|
136
|
+
process.exitCode = code === "module_exists" ? 3 : 2;
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-aiaui-module",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1-TEST",
|
|
4
|
+
"description": "AIAUI Instance module scaffolder: generate editable module (FastAPI + Vue frontend) with one npx command",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"aiaui",
|
|
7
|
+
"scaffolding",
|
|
8
|
+
"generator",
|
|
9
|
+
"vue",
|
|
10
|
+
"fastapi",
|
|
11
|
+
"module",
|
|
12
|
+
"instance"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"create-aiaui-module": "./bin/create-aiaui-module.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"runtime",
|
|
22
|
+
"templates"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node tests/generator.test.js",
|
|
29
|
+
"verify-dist": "node scripts/verify-dist.mjs",
|
|
30
|
+
"prepack": "npm run verify-dist"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Instance-side entry point for enabled AIAUI modules.
|
|
2
|
+
|
|
3
|
+
The owning Instance calls ``AIAUI.start(services={...})`` once its business
|
|
4
|
+
objects are ready and ``aiaui.stop()`` during shutdown. Generated modules
|
|
5
|
+
remain editable; this file only delegates discovery and hosting to the
|
|
6
|
+
inlined ``_runtime`` package (no external ``aiaui`` dependency is required).
|
|
7
|
+
|
|
8
|
+
Typical use::
|
|
9
|
+
|
|
10
|
+
from PyAIAUI import AIAUI
|
|
11
|
+
|
|
12
|
+
aiaui = AIAUI.start(services={"job_repository": repo})
|
|
13
|
+
# ... run the Instance ...
|
|
14
|
+
aiaui.stop()
|
|
15
|
+
|
|
16
|
+
``AIAUI.start()`` returns ``None`` when no modules are enabled in
|
|
17
|
+
``Config/AIAUIProtocol.json``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ._runtime import attach
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AIAUI:
|
|
26
|
+
"""Host handle for one Instance's enabled AIAUI modules.
|
|
27
|
+
|
|
28
|
+
One handle hosts every enabled module of the Instance on a single
|
|
29
|
+
loopback HTTP port (see ``Config/AIAUIProtocol.json``). ``start()`` is
|
|
30
|
+
idempotent per Instance: a second call returns the same handle while the
|
|
31
|
+
host lives, and ``stop()`` tears everything down.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_current: "AIAUI | None" = None
|
|
35
|
+
|
|
36
|
+
def __init__(self, host):
|
|
37
|
+
self._host = host
|
|
38
|
+
self.closed = False
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def start(cls, *, services=None, logger=None) -> "AIAUI | None":
|
|
42
|
+
"""Start this Instance's enabled modules and return the handle.
|
|
43
|
+
|
|
44
|
+
``services`` are the Instance's live business objects; every module
|
|
45
|
+
backend (``create_api(context)``) reaches them through
|
|
46
|
+
``context.services``. Returns ``None`` when no modules are enabled.
|
|
47
|
+
|
|
48
|
+
``start()`` never selects a port, installs dependencies, or starts a
|
|
49
|
+
network socket outside the loopback interface; the port comes from
|
|
50
|
+
the Instance's ``Config/AIAUIProtocol.json``.
|
|
51
|
+
"""
|
|
52
|
+
current = cls._current
|
|
53
|
+
if current is not None and not current.closed:
|
|
54
|
+
return current
|
|
55
|
+
host = attach(
|
|
56
|
+
Path(__file__).resolve().parent.parent,
|
|
57
|
+
services=services,
|
|
58
|
+
logger=logger,
|
|
59
|
+
)
|
|
60
|
+
if host is None:
|
|
61
|
+
return None
|
|
62
|
+
handle = cls(host)
|
|
63
|
+
cls._current = handle
|
|
64
|
+
return handle
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def host(self):
|
|
68
|
+
"""The underlying loopback host, or ``None`` after ``stop()``.
|
|
69
|
+
|
|
70
|
+
Exposed for advanced inspection only; normal code should use
|
|
71
|
+
``stop()`` and let this handle the lifecycle.
|
|
72
|
+
"""
|
|
73
|
+
return None if self.closed else self._host
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None:
|
|
76
|
+
"""Stop every module hosted for this Instance (usually at shutdown)."""
|
|
77
|
+
if self.closed:
|
|
78
|
+
return
|
|
79
|
+
self.closed = True
|
|
80
|
+
self._host.close()
|
|
81
|
+
if AIAUI._current is self:
|
|
82
|
+
AIAUI._current = None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Inlined AIAUI module host runtime for generated Instances.
|
|
2
|
+
|
|
3
|
+
Call attach() from the generated PyAIAUI entry point. This package is copied
|
|
4
|
+
verbatim into ``PyAIAUI/_runtime`` by the module generator so Instances do not
|
|
5
|
+
need an external ``aiaui`` dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .registry import discover
|
|
12
|
+
|
|
13
|
+
__all__ = ["attach", "discover"]
|
|
14
|
+
|
|
15
|
+
_hosts = {}
|
|
16
|
+
_hosts_lock = threading.RLock()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def attach(instance_root, *, services=None, logger=None):
|
|
20
|
+
"""Load every enabled module under PyAIAUI/ and serve them on the fixed proxy port.
|
|
21
|
+
|
|
22
|
+
Returns the running ProxyHost, or None when no modules are enabled.
|
|
23
|
+
Call host.close() during Instance shutdown.
|
|
24
|
+
"""
|
|
25
|
+
from .host import ProxyHost
|
|
26
|
+
from .loader import ModuleContext
|
|
27
|
+
|
|
28
|
+
root = Path(instance_root).resolve()
|
|
29
|
+
with _hosts_lock:
|
|
30
|
+
existing = _hosts.get(root)
|
|
31
|
+
if existing is not None and not existing.closed:
|
|
32
|
+
return existing
|
|
33
|
+
_hosts.pop(root, None)
|
|
34
|
+
port, modules = discover(root)
|
|
35
|
+
if not modules:
|
|
36
|
+
return None
|
|
37
|
+
context = ModuleContext(root, services or {}, logger)
|
|
38
|
+
host = ProxyHost(root, port, modules, context)
|
|
39
|
+
_hosts[root] = host
|
|
40
|
+
return host
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""One loopback HTTP host per Instance; every enabled module mounts its own router here."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .loader import load_api
|
|
8
|
+
from .registry import ModuleDescriptor
|
|
9
|
+
|
|
10
|
+
OWNER = "aiaui-proxy"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProxyHost:
|
|
14
|
+
def __init__(self, instance_root: Path, port: int, modules: list[ModuleDescriptor], context):
|
|
15
|
+
import fcntl
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
import uvicorn
|
|
19
|
+
from fastapi import FastAPI
|
|
20
|
+
|
|
21
|
+
self.root = Path(instance_root)
|
|
22
|
+
self.port = port
|
|
23
|
+
self.modules = modules
|
|
24
|
+
self.closed = False
|
|
25
|
+
self.thread: threading.Thread | None = None
|
|
26
|
+
|
|
27
|
+
config_dir = self.root / "Config"
|
|
28
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
lock_path = config_dir / ".aiaui.host.lock"
|
|
30
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
31
|
+
self._lock_file = os.fdopen(descriptor, "a")
|
|
32
|
+
try:
|
|
33
|
+
fcntl.flock(self._lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
34
|
+
except BlockingIOError as error:
|
|
35
|
+
self._lock_file.close()
|
|
36
|
+
raise ValueError("Another process already hosts this Instance") from error
|
|
37
|
+
|
|
38
|
+
self._app = FastAPI(title=f"AIAUI modules for {self.root.name}")
|
|
39
|
+
for module in modules:
|
|
40
|
+
router = load_api(module, context)
|
|
41
|
+
self._app.include_router(router, prefix=f"/modules/{module.id}")
|
|
42
|
+
self._app.add_api_route(
|
|
43
|
+
"/modules",
|
|
44
|
+
lambda: {"modules": [m.id for m in modules]},
|
|
45
|
+
methods=["GET"],
|
|
46
|
+
include_in_schema=False,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.server = uvicorn.Server(uvicorn.Config(self._app, host="127.0.0.1", port=port))
|
|
50
|
+
# Do not use uvicorn.Server.run(). In some Instance environments asyncio.run
|
|
51
|
+
# is replaced (for example by nest_asyncio) with a wrapper that does not
|
|
52
|
+
# accept ``loop_factory``, which uvicorn 0.54+ passes through. Running the
|
|
53
|
+
# coroutine on an event loop we own keeps this runtime self-contained.
|
|
54
|
+
self.thread = threading.Thread(target=self._serve, daemon=True, name="aiaui-proxy")
|
|
55
|
+
self.thread.start()
|
|
56
|
+
|
|
57
|
+
deadline_limit = 100
|
|
58
|
+
while not self.server.started and deadline_limit > 0:
|
|
59
|
+
if not self.thread.is_alive():
|
|
60
|
+
self.close()
|
|
61
|
+
raise RuntimeError("AIAUI proxy host failed to start")
|
|
62
|
+
time.sleep(0.05)
|
|
63
|
+
deadline_limit -= 1
|
|
64
|
+
if not self.server.started:
|
|
65
|
+
self.close()
|
|
66
|
+
raise RuntimeError("AIAUI proxy host did not become ready")
|
|
67
|
+
self.port = self.server.servers[0].sockets[0].getsockname()[1]
|
|
68
|
+
|
|
69
|
+
def _serve(self):
|
|
70
|
+
import asyncio
|
|
71
|
+
|
|
72
|
+
# Create a fresh loop for this thread instead of relying on the process
|
|
73
|
+
# global ``asyncio.run`` (it may have been patched by the Instance).
|
|
74
|
+
loop = asyncio.new_event_loop()
|
|
75
|
+
asyncio.set_event_loop(loop)
|
|
76
|
+
try:
|
|
77
|
+
loop.run_until_complete(self.server.serve())
|
|
78
|
+
finally:
|
|
79
|
+
loop.close()
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
if self.closed:
|
|
83
|
+
return
|
|
84
|
+
self.closed = True
|
|
85
|
+
if self.thread is not None:
|
|
86
|
+
self.server.should_exit = True
|
|
87
|
+
self.thread.join(timeout=5)
|
|
88
|
+
self._lock_file.close()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Import a module api.py and call its create_api(context) factory. Nothing else is interpreted."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .registry import ModuleDescriptor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModuleContext:
|
|
11
|
+
"""Everything the platform hands to a module backend."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, instance_root, services, logger=None):
|
|
14
|
+
self.instance_root = Path(instance_root)
|
|
15
|
+
self.services = dict(services or {})
|
|
16
|
+
self.logger = logger
|
|
17
|
+
self.directory = instance_root / "PyAIAUI" if False else None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def resources(self) -> Path:
|
|
21
|
+
return self.instance_root / "PyAIAUI"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_api(module: ModuleDescriptor, context: ModuleContext):
|
|
25
|
+
module_name = f"aiaui_module_{module.id}".replace("-", "_")
|
|
26
|
+
spec = importlib.util.spec_from_file_location(module_name, module.api_file)
|
|
27
|
+
if spec is None or spec.loader is None:
|
|
28
|
+
raise ValueError(f"Cannot import {module.api_file}")
|
|
29
|
+
module_object = importlib.util.module_from_spec(spec)
|
|
30
|
+
sys.modules[module_name] = module_object
|
|
31
|
+
try:
|
|
32
|
+
spec.loader.exec_module(module_object)
|
|
33
|
+
except Exception:
|
|
34
|
+
sys.modules.pop(module_name, None)
|
|
35
|
+
raise
|
|
36
|
+
factory = getattr(module_object, "create_api", None)
|
|
37
|
+
if factory is None:
|
|
38
|
+
raise ValueError(f"{module.api_file} must define create_api(context)")
|
|
39
|
+
return factory(context)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Discover modules exclusively from an Instance's AIAUIProtocol contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PROTOCOL_NAME = "AIAUIProtocol.json"
|
|
8
|
+
MODULE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProtocolError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModuleDescriptor:
|
|
16
|
+
"""What the platform needs to know about one module, derived purely by convention."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, module_id: str, directory: Path):
|
|
19
|
+
self.id = module_id
|
|
20
|
+
self.directory = directory
|
|
21
|
+
self.api_file = directory / "api.py"
|
|
22
|
+
self.frontend_entry = directory / "resources" / "dist"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def has_backend(self) -> bool:
|
|
26
|
+
return self.api_file.is_file()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def has_frontend(self) -> bool:
|
|
30
|
+
return self.frontend_entry.is_dir() and any(self.frontend_entry.glob("*.js"))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def frontend_manifest(self) -> Path:
|
|
34
|
+
return self.frontend_entry / "manifest.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_protocol(instance_root: Path) -> dict | None:
|
|
38
|
+
"""Read the new protocol; a null port is valid while all modules are disabled."""
|
|
39
|
+
|
|
40
|
+
path = Path(instance_root) / "Config" / PROTOCOL_NAME
|
|
41
|
+
if path.is_symlink():
|
|
42
|
+
raise ProtocolError(PROTOCOL_NAME + " must be a real file")
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return None
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise ProtocolError(PROTOCOL_NAME + " must be a real file")
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, ValueError) as cause:
|
|
50
|
+
raise ProtocolError(PROTOCOL_NAME + " is missing or invalid") from cause
|
|
51
|
+
if not isinstance(data, dict) or data.get("schemaVersion") != 1:
|
|
52
|
+
raise ProtocolError(PROTOCOL_NAME + " requires schemaVersion 1")
|
|
53
|
+
port = data.get("port")
|
|
54
|
+
if port is not None and (
|
|
55
|
+
not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535
|
|
56
|
+
):
|
|
57
|
+
raise ProtocolError(PROTOCOL_NAME + " port must be null or a valid integer")
|
|
58
|
+
modules = data.get("modules")
|
|
59
|
+
if not isinstance(modules, list):
|
|
60
|
+
raise ProtocolError(PROTOCOL_NAME + " modules must be an array")
|
|
61
|
+
names = set()
|
|
62
|
+
for entry in modules:
|
|
63
|
+
if (
|
|
64
|
+
not isinstance(entry, dict)
|
|
65
|
+
or not isinstance(entry.get("name"), str)
|
|
66
|
+
or not MODULE_NAME.fullmatch(entry["name"])
|
|
67
|
+
or not isinstance(entry.get("enabled"), bool)
|
|
68
|
+
or entry["name"] in names
|
|
69
|
+
):
|
|
70
|
+
raise ProtocolError(PROTOCOL_NAME + " contains an invalid or duplicate module")
|
|
71
|
+
names.add(entry["name"])
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discover(instance_root: Path) -> tuple[int, list[ModuleDescriptor]]:
|
|
76
|
+
"""Return the Instance proxy port and enabled modules from AIAUIProtocol."""
|
|
77
|
+
|
|
78
|
+
root = Path(instance_root)
|
|
79
|
+
modules_root = root / "PyAIAUI"
|
|
80
|
+
protocol = read_protocol(root)
|
|
81
|
+
if protocol is None:
|
|
82
|
+
return 0, []
|
|
83
|
+
names = sorted(entry["name"] for entry in protocol["modules"] if entry["enabled"])
|
|
84
|
+
port = protocol["port"]
|
|
85
|
+
if names and port is None:
|
|
86
|
+
raise ProtocolError(PROTOCOL_NAME + " needs a port before enabling modules")
|
|
87
|
+
descriptors = []
|
|
88
|
+
for name in names:
|
|
89
|
+
directory = modules_root / name
|
|
90
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
91
|
+
raise ProtocolError("PyAIAUI/" + name + " is missing")
|
|
92
|
+
descriptors.append(ModuleDescriptor(name, directory))
|
|
93
|
+
return port or 0, descriptors
|