feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Create reproducible, importable Dify canvases from the application's prompts."""
|
||||
import json
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--provider-id', help='Native model tool provider ID created by dify-model-tools.py')
|
||||
args = parser.parse_args()
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEST = ROOT / 'deploy/dify/workflows'
|
||||
DEST.mkdir(parents=True, exist_ok=True)
|
||||
events = (ROOT / 'internal/extraction/events.go').read_text(encoding='utf-8')
|
||||
memoir = (ROOT / 'internal/memoir/generation.go').read_text(encoding='utf-8')
|
||||
def constant(source, name): return source.split('const '+name+' = `', 1)[1].split('`', 1)[0]
|
||||
extract_prompt = constant(events, 'eventExtractionPrompt')
|
||||
daily_prompt = constant(events, 'dailyExtractionPrompt')
|
||||
book_prompt = constant(memoir, 'MemoirPrompt')
|
||||
|
||||
def node(id, type, title, x, y, **data):
|
||||
return dict(id=id, type='custom', position=dict(x=x,y=y), positionAbsolute=dict(x=x,y=y), width=244, height=110, sourcePosition='right', targetPosition='left', data=dict(type=type,title=title,desc='',selected=False,**data))
|
||||
def edge(a,b,nodes,iteration=None):
|
||||
types={n['id']:n['data']['type'] for n in nodes}
|
||||
data=dict(sourceType=types[a],targetType=types[b],isInIteration=bool(iteration),isInLoop=False)
|
||||
if iteration: data['iteration_id']=iteration
|
||||
return dict(id=a+'-'+b,source=a,target=b,sourceHandle='source',targetHandle='target',type='custom',zIndex=1002 if iteration else 0,data=data)
|
||||
def start():
|
||||
return node('start','start','开始:接收后台任务',50,80,variables=[dict(variable=v,label=l,type='text-input',required=False,max_length=128,options=[],default='') for v,l in [('context_id','任务上下文(后台自动传入;留空可测试示例)'),('step_token','临时执行凭证(留空可测试示例)')]])
|
||||
def http(id,title,step,x,y,kind=None,prompt=None,prepared=True,index=None):
|
||||
selector='prepared' if prepared else 'start'
|
||||
body={'context_id':'{{#'+selector+'.context_id#}}','step_token':'{{#'+selector+'.step_token#}}'}
|
||||
if kind:body['kind']=kind
|
||||
if prompt:body['prompt']=prompt
|
||||
text=json.dumps(body,ensure_ascii=False)
|
||||
if index is not None:text=text[:-1]+',"chapter_index":{{#pictures.item#}}}'
|
||||
return node(id,'http-request',title,x,y,method='POST',url='http://model-bridge:8188/steps/'+step,authorization={'type':'no-auth'},headers='Content-Type:application/json',params='',body={'type':'json','data':[{'id':'body','key':'','type':'text','value':text}]},timeout={'connect':15,'read':240,'write':30},retry_config={'enabled':False,'max_retries':1,'retry_interval':1000})
|
||||
def parse(id,title,source,fields,x,y,allow_errors=False):
|
||||
code='import json\ndef main(body: str, status_code: int) -> dict:\n if status_code != 200: raise Exception("HTTP_STEP_FAILED_"+str(status_code))\n data=json.loads(body)\n'
|
||||
if not allow_errors:code+=' if not data.get("success"): raise Exception(data.get("error_code","MODEL_STEP_FAILED"))\n'
|
||||
code+=' return {'+','.join(repr(k)+': data.get('+repr(k)+','+('""' if typ=='string' else '[]' if typ.startswith('array') else '0')+')' for k,typ in fields.items())+'}\n'
|
||||
return node(id,'code',title,x,y,code_language='python3',code=code,variables=[{'variable':'body','value_selector':[source,'body']},{'variable':'status_code','value_selector':[source,'status_code']}],outputs={k:{'type':t,'children':None} for k,t in fields.items()})
|
||||
def end(source,fields,x,y):
|
||||
return node('end','end','完成:由后台核验并保存',x,y,outputs=[dict(variable=k,value_type=t,value_selector=[source,k]) for k,t in fields.items()])
|
||||
def app(name,description,nodes,edges):
|
||||
return dict(kind='app',version='0.7.0',dependencies=[],app=dict(name=name,description=description,mode='workflow',icon='📖',icon_background='#ECFDF3',use_icon_as_answer_icon=False),workflow=dict(conversation_variables=[],environment_variables=[],features={'file_upload':{'enabled':False},'retriever_resource':{'enabled':False},'sensitive_word_avoidance':{'enabled':False},'speech_to_text':{'enabled':False},'text_to_speech':{'enabled':False},'suggested_questions_after_answer':{'enabled':False}},graph=dict(nodes=nodes,edges=edges,viewport=dict(x=0,y=0,zoom=.7))))
|
||||
|
||||
nodes=[start(),http('prepare','读取聊天与已有故事','prepare',360,80,kind='extract',prepared=False),parse('prepared','接收任务 / 示例测试','prepare',{'context_id':'string','step_token':'string'},670,80),http('classify','DeepSeek:分类与故事拆分','classify',670,300,prompt=extract_prompt),parse('classified','检查故事拆分结果','classify',{'story_count':'number','daily_candidate_count':'number'},360,300),http('daily','DeepSeek:只审核今天实际事件','daily',50,300,prompt=daily_prompt),parse('today','检查当日原话证据','daily',{'today_actual_count':'number'},50,520),http('dedup','去重:同事件比较新增信息','dedup',360,520),parse('merged','输出新增 / 更新 / 重复计划','dedup',{'new_stories':'number','updated_stories':'number','repeated_stories':'number','daily_count':'number'},670,520)]
|
||||
fields={'new_stories':'number','updated_stories':'number','repeated_stories':'number','daily_count':'number'};nodes.append(end('merged',fields,670,740));edges=[edge(a['id'],b['id'],nodes) for a,b in zip(nodes,nodes[1:])]
|
||||
extract=app('聊天整理:当日日常与回忆故事','DeepSeek 分类与事件拆分 → 独立审核今天已发生事项 → 同故事去重与新增信息比较。只记录任务编号和统计;原话、权限检查和故事更新由故事平台负责。',nodes,edges)
|
||||
(DEST/'chat-to-stories.yml').write_text(json.dumps(extract,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
|
||||
nodes=[start(),http('prepare','读取长者全部可用故事','prepare',360,80,kind='memoir',prepared=False),parse('prepared','接收整本任务 / 示例测试','prepare',{'context_id':'string','step_token':'string'},670,80),http('write-book','Doubao:全部故事统一编写整本','write-book',670,300,prompt=book_prompt),parse('written','检查完整章节与插画计划','write-book',{'chapter_count':'number','chapter_indexes':'array[number]'},360,300),http('validate-book','核验来源与日期 · 先显示正文','validate-book',50,300),parse('validated','所有故事均已成章','validate-book',{'chapter_count':'number'},50,520)]
|
||||
next(n for n in nodes if n['id']=='write-book')['data']['timeout']['read']=660
|
||||
iteration=node('pictures','iteration','逐章配图 · 并行 3 章',360,520,iterator_selector=['written','chapter_indexes'],iterator_input_type='array[number]',output_selector=['picture-result','result'],output_type='array[string]',is_parallel=True,parallel_nums=3,error_handle_mode='terminated',start_node_id='pictures-start',width=630,height=235)
|
||||
iteration['width']=630;iteration['height']=235;iteration['zIndex']=1;nodes.append(iteration)
|
||||
inner_start=node('pictures-start','iteration-start','',24,80,isInIteration=True);inner_start.update(type='custom-iteration-start',parentId='pictures',width=44,height=48,draggable=False,selectable=False,zIndex=1002)
|
||||
image=http('illustrate','Seedream:绘制该章情景插画','illustrate',95,65,index=True);image.update(parentId='pictures',extent='parent',zIndex=1002);image['data'].update(isInIteration=True,isInLoop=False,iteration_id='pictures')
|
||||
image_result=node('picture-result','code','保留正文,记录插画结果',365,65,code_language='python3',code='import json\ndef main(body: str) -> dict:\n data=json.loads(body)\n return {"result":json.dumps(data,ensure_ascii=False)}\n',variables=[{'variable':'body','value_selector':['illustrate','body']}],outputs={'result':{'type':'string','children':None}},isInIteration=True,isInLoop=False,iteration_id='pictures');image_result.update(parentId='pictures',extent='parent',zIndex=1002)
|
||||
nodes += [inner_start,image,image_result,http('finish','汇总正文与插画完成情况','finish',50,850),parse('finished','结果核验与图片数量','finish',{'chapter_count':'number','illustrations_ready':'number','illustration_errors':'number'},360,850),end('finished',{'chapter_count':'number','illustrations_ready':'number','illustration_errors':'number'},670,850)]
|
||||
main=['start','prepare','prepared','write-book','written','validate-book','validated','pictures','finish','finished','end'];edges=[edge(a,b,nodes) for a,b in zip(main,main[1:])]+[edge('pictures-start','illustrate',nodes,'pictures'),edge('illustrate','picture-result',nodes,'pictures')]
|
||||
book=app('图文回忆录:全部故事成书与逐章配图','全部故事一次交给 Doubao-Seed-Evolving 统一成书 → 核验章节、事实与日期 → 正文先显示 → Seedream 并行配图 → 后台检查账号授权和来源版本并保存。',nodes,edges)
|
||||
(DEST/'stories-to-memoir.yml').write_text(json.dumps(book,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
if args.provider_id:
|
||||
spec = importlib.util.spec_from_file_location('dify_model_tools', ROOT/'scripts/dify-model-tools.py')
|
||||
tools = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(tools)
|
||||
for workflow, filename in [(extract, 'chat-to-stories.yml'), (book, 'stories-to-memoir.yml')]:
|
||||
workflow['workflow']['graph'] = tools.convert_graph(workflow['workflow']['graph'], args.provider_id)
|
||||
(DEST/filename).write_text(json.dumps(workflow,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
(DEST.parent/'model-tools.openapi.json').write_text(json.dumps(tools.schema(),ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps({'workflows':2,'nodes':[len(extract['workflow']['graph']['nodes']),len(book['workflow']['graph']['nodes'])]}))
|
||||
@@ -60,7 +60,7 @@ try {
|
||||
generated_at = [DateTime]::UtcNow.ToString('o')
|
||||
source_commit = $sourceCommit
|
||||
working_tree_dirty = $statusLines.Count -gt 0
|
||||
schema_migration_target = 58
|
||||
schema_migration_target = [int](($candidatePaths | Where-Object { $_ -match '^migrations/[0-9]+_.+\.up\.sql$' } | ForEach-Object { [int](($_ -split '/')[1] -split '_')[0] } | Measure-Object -Maximum).Maximum)
|
||||
hardware_runtime = 'volc_hardware_agent'
|
||||
files = @($files)
|
||||
}
|
||||
|
||||
+21
-7
@@ -3,20 +3,34 @@ $projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
docker compose config --quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Docker Compose configuration is invalid.' }
|
||||
docker compose up --build -d
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Docker Compose startup failed. Check the error above.' }
|
||||
# A rebuilt API container may have a new address while the web image is cached.
|
||||
docker compose exec -T web nginx -s reload
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not refresh the web proxy after startup.' }
|
||||
|
||||
function Get-LocalServiceURL([string]$Service, [int]$ContainerPort) {
|
||||
$binding = docker compose port $Service $ContainerPort | Select-Object -First 1
|
||||
if ($LASTEXITCODE -ne 0 -or !$binding -or $binding -notmatch ':(\d+)$') {
|
||||
throw "No published port found for $Service."
|
||||
}
|
||||
return "http://localhost:$($Matches[1])"
|
||||
}
|
||||
$apiURL = Get-LocalServiceURL 'api' 8080
|
||||
|
||||
Write-Host 'Waiting for API readiness...'
|
||||
for ($attempt = 1; $attempt -le 60; $attempt++) {
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri 'http://localhost:58080/health/ready' -TimeoutSec 2
|
||||
$response = Invoke-RestMethod -Uri "$apiURL/health/ready" -TimeoutSec 2
|
||||
if ($response.data.status -eq 'ready') {
|
||||
Write-Host 'Local environment is ready:'
|
||||
Write-Host ' Web http://localhost:3100'
|
||||
Write-Host ' API http://localhost:58080'
|
||||
Write-Host ' Callbacks http://localhost:58081'
|
||||
Write-Host ' Worker http://localhost:58082'
|
||||
Write-Host ' MinIO http://localhost:59001'
|
||||
Write-Host ' Keycloak http://localhost:58180'
|
||||
Write-Host " Web $(Get-LocalServiceURL 'web' 8080)"
|
||||
Write-Host " API $apiURL"
|
||||
Write-Host " Callbacks $(Get-LocalServiceURL 'callbacks' 8081)"
|
||||
Write-Host " Worker $(Get-LocalServiceURL 'worker' 8082)"
|
||||
Write-Host " MinIO $(Get-LocalServiceURL 'minio' 9001)"
|
||||
Write-Host " Keycloak $(Get-LocalServiceURL 'keycloak' 8080)"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Native Dify model menus. Run inside the Dify API image; no vendor patch.
|
||||
|
||||
stage APP_ID... --backup-dir PATH installs metadata and saves drafts, never
|
||||
publishes. publish checks the staged hashes and publishes atomically. Backups
|
||||
contain workflow configuration and must remain private on the server.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
PROVIDER = '故事平台 · 分步骤模型'
|
||||
TEXT = [('deepseek-v4-1-flash-260910', 'DeepSeek V4.1 Flash'),
|
||||
('doubao-seed-evolving', 'Doubao Seed Evolving'),
|
||||
('doubao-seed-2-1-pro-260915', 'Doubao Seed 2.1 Pro'),
|
||||
('doubao-seed-2-1-lite-260915', 'Doubao Seed 2.1 Lite')]
|
||||
BOOK = TEXT[1:]
|
||||
IMAGE = [('doubao-seedream-5-0-pro-260628', 'Seedream 5.0 Pro'),
|
||||
('doubao-seedream-5-0-flash-260915', 'Seedream 5.0 Flash'),
|
||||
('doubao-seedream-4-0-20260415', 'Seedream 4.0')]
|
||||
STEPS = {'classify': ('分类与故事拆分', TEXT, TEXT[0][0]),
|
||||
'daily': ('审核今天实际事件', TEXT, TEXT[0][0]),
|
||||
'write-book': ('全部故事统一编写整本', BOOK, BOOK[0][0]),
|
||||
'illustrate': ('绘制该章情景插画', IMAGE, IMAGE[0][0])}
|
||||
|
||||
def i18n(text):
|
||||
return {'en_US': text, 'zh_Hans': text}
|
||||
|
||||
def schema():
|
||||
paths = {}
|
||||
for step, (title, options, default) in STEPS.items():
|
||||
props = {n: {'type': 'string', 'description': d} for n, d in
|
||||
[('context_id', '后台任务编号'), ('step_token', '后台临时执行凭证'),
|
||||
('model', '模型'), ('custom_model', '自定义火山模型 ID 或 ep- 接入点') ]}
|
||||
props['model'].update(default=default, enum=[x[0] for x in options]+['__custom__'])
|
||||
props['custom_model']['default'] = ''
|
||||
if step != 'illustrate': props['prompt'] = {'type': 'string', 'description': '提示词'}
|
||||
else: props['chapter_index'] = {'type': 'integer', 'description': '章节序号'}
|
||||
paths['/steps/'+step] = {'post': {
|
||||
'operationId': step.replace('-', '_'), 'summary': title,
|
||||
'requestBody': {'required': True, 'content': {'application/json': {'schema': {
|
||||
'type': 'object', 'properties': props, 'required': ['context_id', 'step_token', 'model']}}}},
|
||||
'responses': {'200': {'description': '执行状态、实际模型和统计,不含长者原文'}}}}
|
||||
return {'openapi': '3.0.3', 'info': {'title': PROVIDER, 'version': '1.0.0',
|
||||
'description': '在各节点选择火山模型。草稿测试使用虚构示例;发布后用于后续线上任务。'},
|
||||
'servers': [{'url': 'http://model-bridge:8188'}], 'paths': paths}
|
||||
|
||||
def convert_graph(graph, provider_id):
|
||||
graph = copy.deepcopy(graph)
|
||||
changed = set()
|
||||
for n in graph['nodes']:
|
||||
d = n['data']; step = n['id']
|
||||
if step not in STEPS: continue
|
||||
if d['type'] == 'tool':
|
||||
if d.get('provider_id') != provider_id: raise ValueError('node already uses another provider')
|
||||
continue
|
||||
if d['type'] != 'http-request' or d['url'] != 'http://model-bridge:8188/steps/'+step:
|
||||
raise ValueError('unexpected model node; refusing to replace')
|
||||
body = d['body']['data'][0]['value']
|
||||
# Iteration index is a native numeric variable, not a JSON literal.
|
||||
old = json.loads(body.replace('{{#pictures.item#}}', '0'))
|
||||
title, _, default = STEPS[step]
|
||||
# Dify's string editor persists mixed templates. A variable binding on
|
||||
# a string looks empty in 1.17 and is erased when the model is changed.
|
||||
params = {v: {'type': 'mixed', 'value': '{{#prepared.'+v+'#}}'} for v in ['context_id', 'step_token']}
|
||||
if step == 'illustrate': params['chapter_index'] = {'type': 'variable', 'value': ['pictures', 'item']}
|
||||
else: params['prompt'] = {'type': 'mixed', 'value': old.get('prompt', '')}
|
||||
inherit = {k: v for k, v in d.items() if k in ['isInIteration', 'isInLoop', 'iteration_id', 'selected']}
|
||||
n['data'] = dict(type='tool', title=title, desc='选择模型后先测试,再发布;当前运行任务保留原版本。',
|
||||
provider_type='api', provider_id=provider_id, provider_name=PROVIDER,
|
||||
tool_name=step.replace('-', '_'), tool_label=title, tool_node_version='2',
|
||||
tool_configurations={'model': {'type': 'constant', 'value': default},
|
||||
'custom_model': {'type': 'mixed', 'value': ''}},
|
||||
tool_parameters=params, **inherit)
|
||||
changed.add(step)
|
||||
for n in graph['nodes']:
|
||||
d = n['data']
|
||||
if d['type'] != 'code': continue
|
||||
variables = d.get('variables', [])
|
||||
if any(v.get('value_selector', [None])[0] in changed for v in variables):
|
||||
d['variables'] = [v for v in variables if v['variable'] != 'status_code']
|
||||
for v in d['variables']:
|
||||
if v['value_selector'][0] in changed and v['value_selector'][1] == 'body':
|
||||
v['value_selector'][1] = 'text'
|
||||
d['code'] = d['code'].replace('body: str, status_code: int', 'body: str')
|
||||
d['code'] = '\n'.join(l for l in d['code'].split('\n') if 'if status_code != 200:' not in l)
|
||||
types = {n['id']: n['data']['type'] for n in graph['nodes']}
|
||||
for e in graph['edges']:
|
||||
e['data'].update(sourceType=types[e['source']], targetType=types[e['target']])
|
||||
return graph
|
||||
|
||||
def install_provider(account, tenant, session):
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParameter
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from models.tools import ApiToolProvider
|
||||
from services.tools.api_tools_manage_service import ApiToolManageService
|
||||
from sqlalchemy import select
|
||||
p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER))
|
||||
if p is None:
|
||||
ApiToolManageService.create_api_tool_provider(user_id=account.id, tenant_id=tenant,
|
||||
provider_name=PROVIDER, icon={'type': 'emoji', 'value': '🧠', 'background': '#EFF4FF'},
|
||||
credentials={'auth_type': 'none'}, schema_type=ApiProviderSchemaType.OPENAPI,
|
||||
schema=json.dumps(schema(), ensure_ascii=False), privacy_policy='', custom_disclaimer='', labels=[])
|
||||
p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER))
|
||||
# Dify 1.17's OpenAPI parser discards enum/form. Enrich its native serialized
|
||||
# bundles so the supported SELECT UI and runtime read the same metadata.
|
||||
bundles, _ = ApiToolManageService.convert_schema_to_tool_bundles(json.dumps(schema(), ensure_ascii=False), {})
|
||||
for b in bundles:
|
||||
_, options, default = STEPS[b.operation_id.replace('_', '-')]
|
||||
for param in b.parameters:
|
||||
if param.name == 'model':
|
||||
param.type = ToolParameter.ToolParameterType.SELECT
|
||||
param.form = ToolParameter.ToolParameterForm.FORM
|
||||
param.label = I18nObject(**i18n('模型'))
|
||||
param.default = default
|
||||
param.options = [PluginParameterOption(value=v, label=i18n(label)) for v, label in options]+[
|
||||
PluginParameterOption(value='__custom__', label=i18n('自定义模型 / 接入点'))]
|
||||
elif param.name == 'custom_model':
|
||||
param.form = ToolParameter.ToolParameterForm.FORM
|
||||
param.label = I18nObject(**i18n('自定义模型 ID(选择自定义时填写)'))
|
||||
param.default = ''
|
||||
else:
|
||||
param.form = ToolParameter.ToolParameterForm.LLM
|
||||
p.schema = json.dumps(schema(), ensure_ascii=False)
|
||||
p.tools_str = json.dumps([b.model_dump(mode='json') for b in bundles], ensure_ascii=False)
|
||||
session.commit()
|
||||
return p.id
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=['stage', 'publish', 'rollback'])
|
||||
parser.add_argument('apps', nargs='+')
|
||||
parser.add_argument('--backup-dir', required=True)
|
||||
args = parser.parse_args()
|
||||
from app_factory import create_app
|
||||
from extensions.ext_database import db
|
||||
from models import App, Workflow
|
||||
from models.account import Account
|
||||
from services.workflow_service import WorkflowService
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
_, flask = create_app()
|
||||
dest = Path(args.backup_dir); dest.mkdir(parents=True, exist_ok=True)
|
||||
dest.chmod(0o700)
|
||||
with flask.app_context(), sessionmaker(db.engine, expire_on_commit=False)() as session:
|
||||
apps = [session.get(App, id) for id in args.apps]
|
||||
assert all(apps) and len({a.tenant_id for a in apps}) == 1
|
||||
account = session.get(Account, apps[0].created_by)
|
||||
service = WorkflowService()
|
||||
if args.mode == 'stage':
|
||||
provider = install_provider(account, apps[0].tenant_id, session)
|
||||
receipts = []
|
||||
for a in apps:
|
||||
draft = session.scalar(select(Workflow).where(Workflow.app_id == a.id, Workflow.version == 'draft'))
|
||||
target = dest/(a.id+'.json')
|
||||
if args.mode == 'stage':
|
||||
if target.exists(): raise ValueError('backup exists; use a new backup directory')
|
||||
published = session.get(Workflow, a.workflow_id)
|
||||
backup = {'app_id': a.id, 'published_id': a.workflow_id, 'draft_graph': draft.graph_dict,
|
||||
'published_graph': published.graph_dict, 'original_hash': draft.unique_hash}
|
||||
graph = convert_graph(draft.graph_dict, provider)
|
||||
service.sync_draft_workflow(app_model=a, graph=graph, features=draft.features_dict,
|
||||
unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[],
|
||||
session=session, graph_only=True, commit=False)
|
||||
session.flush()
|
||||
backup['staged_hash'] = draft.unique_hash
|
||||
target.write_text(json.dumps(backup, ensure_ascii=False, indent=2)); target.chmod(0o600)
|
||||
receipts.append({'app_id': a.id, 'provider_id': provider, 'staged_hash': draft.unique_hash})
|
||||
elif args.mode == 'publish':
|
||||
backup = json.loads(target.read_text())
|
||||
if a.workflow_id != backup['published_id'] or draft.unique_hash != backup['staged_hash']:
|
||||
raise ValueError('workflow changed since staging; aborting publish')
|
||||
published = service.publish_workflow(session=session, app_model=a, account=account,
|
||||
marked_name='各步骤模型可配置', marked_comment='保留原模型和提示词;模型在节点菜单中选择。')
|
||||
a.workflow_id = published.id
|
||||
receipts.append({'app_id': a.id, 'workflow_id': published.id})
|
||||
else:
|
||||
backup = json.loads(target.read_text())
|
||||
service.sync_draft_workflow(app_model=a, graph=backup['draft_graph'], features=draft.features_dict,
|
||||
unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[],
|
||||
session=session, graph_only=True, commit=False)
|
||||
a.workflow_id = backup['published_id']
|
||||
receipts.append({'app_id': a.id, 'restored': True})
|
||||
session.commit()
|
||||
print(json.dumps({'mode': args.mode, 'workflows': receipts}, ensure_ascii=False))
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,107 @@
|
||||
// Local-only documentation export. Never print or copy a device key into the repository.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
async function main() {
|
||||
const input = path.resolve(process.argv[2] || '');
|
||||
if (!process.argv[2] || path.basename(input) !== 'device-init.private.json') {
|
||||
throw Error('Provide the local device-init.private.json path');
|
||||
}
|
||||
const repo = path.resolve(__dirname, '..');
|
||||
const folder = path.dirname(input);
|
||||
const relative = path.relative(repo, folder);
|
||||
if (!relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
|
||||
throw Error('Private export must be outside the repository');
|
||||
}
|
||||
const init = JSON.parse(fs.readFileSync(input, 'utf8').replace(/^\uFEFF/, ''));
|
||||
if (init.factory_id !== 'KP01-020000260914' ||
|
||||
init.origin !== 'https://82.157.201.114' ||
|
||||
!/^[0-9a-f]{64}$/i.test(init.device_key_hex)) {
|
||||
throw Error('Unexpected private identity; values not printed');
|
||||
}
|
||||
const fields = ['factory_id', 'model', 'origin', 'device_key_hex', 'key_version'];
|
||||
const values = Object.fromEntries(fields.map(key => [key, init[key]]));
|
||||
if (Object.values(values).some(value => value === undefined)) throw Error('Missing initialization field');
|
||||
|
||||
const source = path.join(repo, 'docs/embedded-device-business-guide-20260914.md');
|
||||
const publicMarkdown = fs.readFileSync(source, 'utf8').replace(/\r\n/g, '\n');
|
||||
if (publicMarkdown.includes(init.device_key_hex)) throw Error('Private key found in public source');
|
||||
const sectionStart = publicMarkdown.indexOf('## 1. 设备需要烧录的字段');
|
||||
const sectionEnd = publicMarkdown.indexOf('## 2. 蓝牙协议与名称约定');
|
||||
if (sectionStart < 0 || sectionEnd < sectionStart) throw Error('Unexpected source structure');
|
||||
|
||||
const actualHeader = [
|
||||
'# VoiceLink 设备完整接口文档(私有交接)', '',
|
||||
'2026-09-15 · 设备接口专用版 · 蓝牙配网、绑定、语音聊天、MQTT 心跳、OTA', '',
|
||||
'## 1. 实际需要烧录的内容', '',
|
||||
'以下 5 项是平台已登记的这台独立联调设备的实际值,不是占位符。只给一台联调设备使用;这是测试身份,不是测得的实物 MAC,也不是最终硬件型号。不同实物需分别在平台登记。', '',
|
||||
'```json', JSON.stringify(values, null, 2), '```', '',
|
||||
'**本文件含真实设备密钥,仅在本机/受控交接中使用,不上传公开仓库或公开网页。**', '',
|
||||
'| 烧录字段 | 中文解释与用途 |', '|---|---|',
|
||||
'| factory_id | 设备唯一编号;设备接口的 device_id、请求头 X-Device-ID 使用它 |',
|
||||
'| model | 平台登记的设备型号;广播名称和 OTA 型号核对使用它 |',
|
||||
'| origin | 平台 HTTPS 地址 |',
|
||||
'| device_key_hex | 解码成 32 字节设备密钥;用于现有 HTTP 请求签名,不是火山密钥 |',
|
||||
'| key_version | 上述设备密钥的版本,当前为 1 |', '',
|
||||
`本设备蓝牙名称:\`vl-${values.model}-${values.factory_id.slice(-8)}\`。由型号和设备编号拼接,不是额外烧录字段。`, '',
|
||||
'固件另外带实际运行的 `firmware_version` 和数字 `version_code`;未对应平台发布记录时顺序号填 0。它们属于固件构建信息。hardware_revision 仅是库存信息,新设备接口无需烧录或上报。', '',
|
||||
'不用烧录 Wi-Fi、账号、老人 ID、智能体 ID、火山凭据或 MQTT 密码。Wi-Fi 和 bind_token 由小程序发送;绑定成功后平台直接下发火山专属凭据;MQTT 配置联网获取。', '',
|
||||
'平台:`https://82.157.201.114`。聊天继续按每通 `session_id` 关联火山回调,不改成固定老人 ID。', '',
|
||||
'本文只保留设备直接参与的通信。阅读顺序:第 2–5 节配网绑定 → 附录 B 本地初始化 SDK(不请求平台登记)→ 第 6–7 节聊天 → 第 8–10 节 MQTT 心跳与 OTA。设备平台请求统一按附录 A 封装。示例时间、版本、任务 ID 和下载链接不能写死。', '',
|
||||
].join('\n');
|
||||
const cFolder = path.join(folder, 'device-protocol-c');
|
||||
fs.mkdirSync(cFolder, { recursive: true });
|
||||
const codeBlocks = ['vl_protocol.h', 'vl_protocol.c'].map(filename => {
|
||||
const code = fs.readFileSync(path.join(repo, 'docs/device-protocol-c', filename), 'utf8');
|
||||
fs.writeFileSync(path.join(cFolder, filename), code);
|
||||
return '### ' + filename + '\n\n```c\n' + code.trimEnd() + '\n```\n';
|
||||
}).join('\n');
|
||||
const markdown = (actualHeader + '\n' + publicMarkdown.slice(sectionEnd))
|
||||
.replace('<!-- VL_PROTOCOL_C_SOURCE -->', codeBlocks);
|
||||
const { marked } = await import(pathToFileURL(require.resolve('marked')).href);
|
||||
let jsonExamples = 0, tables = 0, headings = 0;
|
||||
marked.walkTokens(marked.lexer(markdown), token => {
|
||||
if (token.type === 'code' && token.lang === 'json') {
|
||||
try { JSON.parse(token.text); } catch { throw Error('Invalid JSON example; content not printed'); }
|
||||
jsonExamples++;
|
||||
}
|
||||
if (token.type === 'table') {
|
||||
tables++;
|
||||
if (token.rows.some(row => row.length !== token.header.length)) throw Error('Malformed table');
|
||||
}
|
||||
if (token.type === 'heading' && token.depth === 2) headings++;
|
||||
if (token.type === 'link' && !/^(https?:|#)/.test(token.href)) {
|
||||
if (!fs.existsSync(path.join(folder, decodeURIComponent(token.href.split('#')[0])))) {
|
||||
throw Error('Missing handoff reference');
|
||||
}
|
||||
}
|
||||
});
|
||||
const requiredRoutes = ['ble-bind', 'ble-state', 'voice-config', 'chat/start', 'chat/end', 'mqtt-config', 'ota/report'];
|
||||
for (const route of requiredRoutes) {
|
||||
if (!markdown.includes('/api/v1/device/' + route)) throw Error('Missing required device route: ' + route);
|
||||
}
|
||||
if (/\/api\/v1\/(?:devices\/|ops\/)|X-Login-Portal|X-Tenant-Id|第 8–12 节/.test(markdown)) {
|
||||
throw Error('Non-device interfaces or stale references found');
|
||||
}
|
||||
if (/\/api\/v1\/device\/(register|time-challenges|voice-runtime|activation-acks)|"boot_id"|CFG1|TIME1/.test(markdown)) {
|
||||
throw Error('Removed startup handshake reappeared');
|
||||
}
|
||||
if (headings !== 13 || jsonExamples < 31 || !markdown.includes('/callbacks/v1/volcengine/chat?session_id=') ||
|
||||
markdown.includes('固定老人回调方案调整')) throw Error('Incomplete or stale document');
|
||||
const firstJSON = /```json\n([\s\S]*?)\n```/.exec(markdown);
|
||||
if (!firstJSON || JSON.stringify(JSON.parse(firstJSON[1])) !== JSON.stringify(values)) {
|
||||
throw Error('Actual initialization must be first JSON block');
|
||||
}
|
||||
const output = path.join(folder, '设备完整接口文档-20260915.private.md');
|
||||
fs.writeFileSync(output, markdown, { mode: 0o600 });
|
||||
if (fs.readFileSync(output, 'utf8') !== markdown) throw Error('Export mismatch');
|
||||
// Refresh the old private entry point too, so it cannot retain the discarded elder callback proposal.
|
||||
const shortOutput = path.join(folder, '设备实际烧录信息.private.md');
|
||||
fs.writeFileSync(shortOutput, actualHeader + '\n完整流程见 [设备完整接口文档](设备完整接口文档-20260915.private.md)。\n', { mode: 0o600 });
|
||||
console.log(JSON.stringify({ output, shortOutput, headings, jsonExamples, tables,
|
||||
actualInitializationFirst: true, callbackMode: 'session_id', cHelpersIncluded: true,
|
||||
sha256: crypto.createHash('sha256').update(markdown).digest('hex') }));
|
||||
}
|
||||
main().catch(() => { console.error('Private handoff export failed; no private values logged.'); process.exitCode = 1; });
|
||||
@@ -0,0 +1,2 @@
|
||||
// Keep the old command as an alias so it cannot regenerate obsolete startup fields.
|
||||
require('./export-private-device-handoff.cjs');
|
||||
@@ -0,0 +1,30 @@
|
||||
// Local fixture setup only. Run explicitly after the local seed and migration 61.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, "postgres://voice:voice_dev_only@127.0.0.1:55432/voice_platform?sslmode=disable")
|
||||
check(err)
|
||||
defer pool.Close()
|
||||
r := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379"})
|
||||
defer r.Close()
|
||||
s, err := onboarding.NewService(pool, r, onboarding.Options{MasterKey: os.Getenv("ONBOARDING_MASTER_KEY"), EntryURL: "http://localhost:3100/onboarding/entry", AllowLocalHTTP: true})
|
||||
check(err)
|
||||
check(s.ProvisionLocalDemoPhone(ctx, os.Getenv("LOCAL_DEMO_PASSWORD")))
|
||||
fmt.Println("Local demo phone account provisioned; existing credentials preserved.")
|
||||
}
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Smoke test restricted to the independent embedded handoff identity. No binding,
|
||||
// provider registration, firmware upload, or OTA instruction is performed.
|
||||
const fs=require('node:fs'),crypto=require('node:crypto'),net=require('node:net'),assert=require('node:assert/strict');
|
||||
const {sign}=require('../docs/embedded-signing-reference.cjs');
|
||||
function text(v){const b=Buffer.from(v),h=Buffer.alloc(2);h.writeUInt16BE(b.length);return Buffer.concat([h,b]);}
|
||||
function packet(type,body){const h=[type];let n=body.length;do{let b=n%128;n=Math.floor(n/128);if(n)b|=128;h.push(b);}while(n);return Buffer.concat([Buffer.from(h),body]);}
|
||||
(async()=>{
|
||||
const init=JSON.parse(fs.readFileSync(process.argv[2],'utf8').replace(/^\uFEFF/,''));assert.equal(init.factory_id,'KP01-020000260914');assert.equal(init.origin,'https://82.157.201.114');
|
||||
async function post(path,body){const raw=JSON.stringify(body);const s=sign(Buffer.from(init.device_key_hex,'hex'),{path,raw,deviceId:init.factory_id,keyVersion:init.key_version,timestamp:Date.now(),nonce:crypto.randomBytes(16).toString('base64url'),requestId:crypto.randomUUID()});const r=await fetch(init.origin+path,{method:'POST',headers:s.headers,body:raw,redirect:'error',signal:AbortSignal.timeout(15000)});return {status:r.status,body:await r.json()};}
|
||||
const state=await post('/api/v1/device/ble-state',{});assert.equal(state.body.data.bound,false);
|
||||
const response=await post('/api/v1/device/mqtt-config',{});assert.equal(response.status,200);const config=response.body.data;assert.equal(config.broker_url,'mqtt://82.157.201.114:1883');
|
||||
const missing=await post('/api/v1/device/ota/report',{task_id:crypto.randomUUID(),status:'failed',message:'smoke test - nonexistent task'});assert.equal(missing.status,409);assert.equal(missing.body.code,'OTA_NOT_FOUND');
|
||||
const host=new URL(config.broker_url);const tunnel=process.env.MQTT_TUNNEL_PORT;if(tunnel)assert(/^\d{4,5}$/.test(tunnel));
|
||||
const socket=net.connect({host:tunnel?'127.0.0.1':host.hostname,port:Number(tunnel||host.port)});
|
||||
let buffer=Buffer.alloc(0),queue=[],waiter;
|
||||
const receive=()=>new Promise((resolve,reject)=>{if(queue.length){resolve(queue.shift());return;}const timer=setTimeout(()=>{waiter=null;reject(new Error('MQTT response timeout'));},8000);waiter={resolve:v=>{clearTimeout(timer);waiter=null;resolve(v);},reject:e=>{clearTimeout(timer);waiter=null;reject(e);}};});
|
||||
socket.on('data',b=>{buffer=Buffer.concat([buffer,b]);while(buffer.length>1){let size=0,m=1,i=1,done=false;for(;i<buffer.length&&i<5;i++){const v=buffer[i];size+=(v&127)*m;m*=128;if(!(v&128)){i++;done=true;break;}}if(!done||buffer.length<i+size)return;const p={type:buffer[0],body:buffer.subarray(i,i+size)};buffer=buffer.subarray(i+size);if(waiter)waiter.resolve(p);else queue.push(p);}});
|
||||
socket.on('error',e=>{if(waiter)waiter.reject(e);});
|
||||
try{
|
||||
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{socket.destroy();reject(new Error('MQTT TCP timeout'));},10000);socket.once('connect',()=>{clearTimeout(timer);resolve();});socket.once('error',e=>{clearTimeout(timer);reject(e);});});
|
||||
socket.write(packet(0x10,Buffer.concat([text('MQTT'),Buffer.from([4,0xc2,0,60]),text(config.client_id),text(config.username),text(config.password)])));
|
||||
let p=await receive();assert.equal(p.type,0x20);assert.equal(p.body[1],0);
|
||||
socket.write(packet(0x82,Buffer.concat([Buffer.from([0,1]),text(config.ota_topic),Buffer.from([1])])));p=await receive();assert.equal(p.type,0x90);assert.equal(p.body[2],1);
|
||||
socket.write(packet(0x32,Buffer.concat([text(config.status_topic),Buffer.from([0,2]),Buffer.from(JSON.stringify({online:true,firmware_version:'handoff-mqtt-simulator',version_code:0,ota_supported:true}))])));p=await receive();assert.equal(p.type,0x40);
|
||||
socket.write(Buffer.from([0xe0,0]));
|
||||
console.log(JSON.stringify({device_id:init.factory_id,config_status:response.status,transport_encrypted:false,mqtt_connected:true,ota_subscription:true,heartbeat_ack:true,ota_report_route:true,real_device_modified:false}));
|
||||
}finally{socket.end();socket.destroy();}
|
||||
})().catch(e=>{console.error('MQTT probe failed:',e.code||e.name,'No credentials printed.');process.exitCode=1;});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Read-only production checks on the independent handoff device. Never bind it,
|
||||
// start a real conversation or print private keys/voice/MQTT credentials.
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const assert = require('node:assert/strict');
|
||||
const { sign } = require('../docs/embedded-signing-reference.cjs');
|
||||
async function main() {
|
||||
const init = JSON.parse(fs.readFileSync(process.argv[2], 'utf8').replace(/^\uFEFF/, ''));
|
||||
assert.equal(init.factory_id, 'KP01-020000260914');
|
||||
assert.equal(init.origin, 'https://82.157.201.114');
|
||||
const health = await fetch(init.origin + '/health/ready', { signal: AbortSignal.timeout(15000) });
|
||||
assert.equal(health.status, 200);
|
||||
const offset = Date.parse(health.headers.get('date')) - Date.now();
|
||||
assert.ok(Number.isFinite(offset));
|
||||
const post = async (path, input) => {
|
||||
const raw = JSON.stringify(input);
|
||||
const headers = sign(Buffer.from(init.device_key_hex, 'hex'), { path, deviceId: init.factory_id,
|
||||
keyVersion: init.key_version, timestamp: Date.now() + offset, nonce: crypto.randomBytes(16).toString('base64url'), requestId: crypto.randomUUID(), raw }).headers;
|
||||
const response = await fetch(init.origin + path, { method: 'POST', headers, body: raw, signal: AbortSignal.timeout(15000) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
const state = await post('/api/v1/device/ble-state', {});
|
||||
assert.equal(state.status, 200);
|
||||
assert.equal(state.body.data.bound, false, 'Test device is bound: skip any chat probe');
|
||||
const start = await post('/api/v1/device/chat/start', { device_id: init.factory_id, session_id: crypto.randomUUID() });
|
||||
assert.equal(start.status, 409);
|
||||
assert.equal(start.body.code, 'DEVICE_UNBOUND');
|
||||
const mqtt = await new Promise(resolve => {
|
||||
const socket = net.connect({ host: '82.157.201.114', port: 1883 });
|
||||
socket.setTimeout(8000);
|
||||
socket.once('connect', () => { resolve('TCP_OK'); socket.destroy(); });
|
||||
socket.once('timeout', () => { resolve('TIMEOUT'); socket.destroy(); });
|
||||
socket.once('error', error => { resolve(error.code || 'TCP_FAILED'); socket.destroy(); });
|
||||
});
|
||||
console.log(JSON.stringify({ health: health.status, device_id: init.factory_id, bound: false,
|
||||
chat_without_boot_id: start.status, chat_error: start.body.code, mqtt_public_tcp: mqtt,
|
||||
startup_endpoints_called: 0, bindings_changed: 0, real_calls_started: 0 }));
|
||||
}
|
||||
main().catch(() => { console.error('Live probe failed; no private values printed.'); process.exitCode = 1; });
|
||||
@@ -0,0 +1,12 @@
|
||||
// Mechanical sync of the maintained MQTT/OTA contract into the existing handoff.
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const target = path.join(root, 'docs/embedded-device-business-guide-20260914.md');
|
||||
const source = fs.readFileSync(path.join(root, 'docs/device-mqtt-ota.md'), 'utf8');
|
||||
let md = fs.readFileSync(target, 'utf8');
|
||||
const start=md.indexOf('## 8.'),end=md.indexOf('## 附录 A.');
|
||||
if(start<0||end<start)throw new Error('Expected handoff sections not found');
|
||||
md=md.slice(0,start)+source.slice(source.indexOf('## 8.'))+'\n\n'+md.slice(end);
|
||||
md=md.replace(/^第 1–7 节.*$/m,'第 1–7 节保留现有蓝牙绑定与平台代注册火山流程。第 8–12 节已按本次实现更新;最终硬件按协议对接,本次不修改或烧录 EchoEar。线上发布与验证状态见 `device-fleet-delivery.md`。');
|
||||
fs.writeFileSync(target,md);
|
||||
console.log('Updated MQTT/OTA business contract in the embedded handoff.');
|
||||
@@ -0,0 +1,33 @@
|
||||
// Compile the portable documentation examples with real Mbed TLS, no device/network.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { vectors } = require('../docs/embedded-signing-reference.cjs');
|
||||
const compiler = process.env.VL_HOST_CC || 'E:/Qt/Tools/mingw1120_64/bin/gcc.exe';
|
||||
const tls = process.env.VL_MBEDTLS_DIR || 'E:/Codex/toolchains/esp-idf-v5.5.1/components/mbedtls/mbedtls';
|
||||
const sources = path.resolve(__dirname, '../docs/device-protocol-c');
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), 'vl-protocol-test-'));
|
||||
const vector = vectors();
|
||||
const key = Buffer.from(vector.testKeyHex, 'hex');
|
||||
const payload = '{"device_id":"OFFLINE-TEST","server_time_ms":1789459200000}';
|
||||
const base64 = Buffer.from(payload).toString('base64url');
|
||||
const hmac = text => crypto.createHmac('sha256', key).update(text).digest('hex');
|
||||
const sha = text => crypto.createHash('sha256').update(text).digest('hex');
|
||||
const definitions = { TEST_KEY: vector.testKeyHex, BODY_SHA: vector.bodySha256,
|
||||
HTTP_SIGNATURE: vector.signature, EMPTY_SHA: sha(''), ABC_SHA: sha('abc'),
|
||||
PAYLOAD_JSON: payload, PAYLOAD_B64: base64, TIME_MAC: hmac('TIME1\n' + base64), CFG_MAC: hmac('CFG1\n' + base64) };
|
||||
fs.writeFileSync(path.join(output, 'vectors.generated.h'), Object.entries(definitions)
|
||||
.map(([name, value]) => `#define ${name} ${JSON.stringify(value)}`).join('\n'));
|
||||
const exe = path.join(output, 'test-protocol.exe');
|
||||
const args = ['-std=c99', '-Wall', '-Wextra', '-Werror', '-O2',
|
||||
'-DMBEDTLS_CONFIG_FILE="host_mbedtls_config.h"', '-I' + sources, '-I' + output,
|
||||
'-I' + path.join(tls, 'include'), '-I' + path.join(tls, 'library'),
|
||||
path.join(sources, 'vl_protocol.c'), path.join(sources, 'test_protocol.c'),
|
||||
...['md.c', 'sha256.c', 'base64.c', 'platform.c', 'platform_util.c', 'constant_time.c'].map(file => path.join(tls, 'library', file)),
|
||||
'-o', exe];
|
||||
const env = { ...process.env, PATH: path.dirname(compiler) + path.delimiter + process.env.PATH };
|
||||
execFileSync(compiler, args, { stdio: 'inherit', windowsHide: true, env });
|
||||
process.stdout.write(execFileSync(exe, [], { encoding: 'utf8', windowsHide: true, env }));
|
||||
console.log(JSON.stringify({ output, networkRequests: 0, liveDeviceKeysUsed: false }));
|
||||
@@ -0,0 +1,76 @@
|
||||
param([string]$OutputDirectory='.tmp-elder-persona-sync')
|
||||
$ErrorActionPreference='Stop'
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
$taskRoot=Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $taskRoot
|
||||
$taskSuffix=[guid]::NewGuid().ToString('N').Substring(0,12)
|
||||
$taskNetwork="codex-persona-$taskSuffix"
|
||||
$taskPostgres="codex-persona-pg-$taskSuffix"
|
||||
$taskRedis="codex-persona-redis-$taskSuffix"
|
||||
$taskPreviousDatabase=$env:TEST_DATABASE_URL
|
||||
$taskPreviousRedis=$env:TEST_REDIS_ADDR
|
||||
$taskOutput=Join-Path $taskRoot $OutputDirectory
|
||||
New-Item -ItemType Directory -Force -Path $taskOutput | Out-Null
|
||||
function Run-Migrate([string[]]$MigrationArgs){
|
||||
docker run --rm --pull=never --network $taskNetwork --mount "type=bind,source=$taskRoot/migrations,target=/migrations,readonly" migrate/migrate:v4.18.3 -path=/migrations "-database=postgres://voice:voice_test_only@$taskPostgres`:5432/persona_template?sslmode=disable" @MigrationArgs
|
||||
}
|
||||
try{
|
||||
docker network create $taskNetwork | Out-Null
|
||||
docker run -d --pull=never --name $taskPostgres --network $taskNetwork --tmpfs /var/lib/postgresql/data -e POSTGRES_USER=voice -e POSTGRES_PASSWORD=voice_test_only -e POSTGRES_DB=persona_template -p '127.0.0.1::5432' pgvector/pgvector:pg16 | Out-Null
|
||||
docker run -d --pull=never --name $taskRedis --network $taskNetwork -p '127.0.0.1::6379' redis:7-alpine | Out-Null
|
||||
$taskReady=$false
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
for($taskAttempt=0;$taskAttempt -lt 40;$taskAttempt++){
|
||||
docker exec $taskPostgres pg_isready -U voice -d persona_template *> $null
|
||||
if($LASTEXITCODE -eq 0){$taskReady=$true;break}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
if(!$taskReady){throw 'Isolated PostgreSQL did not become ready'}
|
||||
Run-Migrate @('goto','75') *> (Join-Path $taskOutput 'migrate-75.log')
|
||||
docker cp test/fixtures/local_seed.sql "${taskPostgres}:/tmp/local_seed.sql"
|
||||
docker exec $taskPostgres psql -U voice -d persona_template -v ON_ERROR_STOP=1 -f /tmp/local_seed.sql *> (Join-Path $taskOutput 'seed.log')
|
||||
$taskBackfillSQL=@'
|
||||
INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status,config) VALUES('10000000-0000-0000-0000-000000000401','backfill-test','backfill-test','READY','{"S2SConfig":{"ProviderParams":{"dialog":{"system_role":"保留基础角色\n你正在陪伴的长者称呼是测试长者。"}}}}');
|
||||
'@
|
||||
$taskBackfillFile=Join-Path $taskOutput 'backfill.sql'
|
||||
[IO.File]::WriteAllText($taskBackfillFile,$taskBackfillSQL,[Text.UTF8Encoding]::new($false))
|
||||
docker cp $taskBackfillFile "${taskPostgres}:/tmp/backfill.sql"
|
||||
docker exec $taskPostgres psql -U voice -d persona_template -v ON_ERROR_STOP=1 -f /tmp/backfill.sql | Out-Null
|
||||
Run-Migrate @('up') *> (Join-Path $taskOutput 'migrate-76.log')
|
||||
$taskBackfillResult=docker exec $taskPostgres psql -U voice -d persona_template -At -c "SELECT persona_base_role='保留基础角色' AND persona_sync_state='PENDING' AND persona_synced_revision=0 FROM subject_voice_agent WHERE bot_id='backfill-test';"
|
||||
if($taskBackfillResult -ne 't'){throw 'Existing Bot migration/backfill failed'}
|
||||
docker exec $taskPostgres psql -U voice -d persona_template -v ON_ERROR_STOP=1 -c "DELETE FROM subject_voice_agent WHERE bot_id='backfill-test';" | Out-Null
|
||||
$taskPgBinding=docker port $taskPostgres 5432/tcp
|
||||
$taskRedisBinding=docker port $taskRedis 6379/tcp
|
||||
if($taskPgBinding -notmatch '^127\.0\.0\.1:(\d+)$'){throw 'Unexpected test database binding'}
|
||||
$taskPgPort=$Matches[1]
|
||||
if($taskRedisBinding -notmatch '^127\.0\.0\.1:(\d+)$'){throw 'Unexpected test Redis binding'}
|
||||
$taskRedisPort=$Matches[1]
|
||||
$taskSummary=@()
|
||||
foreach($taskPackage in @('elderagent','saas','callmemory','httpapi')){
|
||||
$taskDatabase="persona_test_$taskPackage"
|
||||
docker exec $taskPostgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskDatabase TEMPLATE persona_template;" | Out-Null
|
||||
$env:TEST_DATABASE_URL="postgres://voice:voice_test_only@127.0.0.1:$taskPgPort/$taskDatabase`?sslmode=disable"
|
||||
$env:TEST_REDIS_ADDR="127.0.0.1:$taskRedisPort"
|
||||
$taskLog=Join-Path $taskOutput "integration-$taskPackage.jsonl"
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
go test -count=1 -p 1 -json "./internal/$taskPackage" *> $taskLog
|
||||
$taskTestExit=$LASTEXITCODE
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
$taskEvents=Get-Content -LiteralPath $taskLog | ForEach-Object {try{$_|ConvertFrom-Json}catch{}}
|
||||
$taskSummary+=@{package=$taskPackage;passed=@($taskEvents|Where-Object{$_.Action -eq 'pass' -and $_.Test}).Count;skipped=@($taskEvents|Where-Object{$_.Action -eq 'skip' -and $_.Test}).Count;exit_code=$taskTestExit}
|
||||
if($taskTestExit -ne 0){Get-Content -LiteralPath $taskLog -Tail 90;throw "Integration tests failed: $taskPackage"}
|
||||
}
|
||||
Run-Migrate @('down','1') *> (Join-Path $taskOutput 'migration-down.log')
|
||||
Run-Migrate @('up') *> (Join-Path $taskOutput 'migration-up-again.log')
|
||||
$taskSummary|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $taskOutput 'results.json')
|
||||
$taskSummary|ConvertTo-Json
|
||||
}finally{
|
||||
$env:TEST_DATABASE_URL=$taskPreviousDatabase
|
||||
$env:TEST_REDIS_ADDR=$taskPreviousRedis
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
# Names are generated by this invocation; never touch Compose or other services.
|
||||
docker rm -f $taskPostgres $taskRedis *> $null
|
||||
docker network rm $taskNetwork *> $null
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
param([string]$OutputDirectory='.tmp-initial-memory')
|
||||
$ErrorActionPreference='Stop'
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
$taskRoot=Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $taskRoot
|
||||
$taskSuffix=[guid]::NewGuid().ToString('N').Substring(0,12)
|
||||
$taskNetwork="codex-initial-memory-$taskSuffix"
|
||||
$taskPostgres="codex-initial-memory-pg-$taskSuffix"
|
||||
$taskRedis="codex-initial-memory-redis-$taskSuffix"
|
||||
$taskPreviousDatabase=$env:TEST_DATABASE_URL
|
||||
$taskPreviousRedis=$env:TEST_REDIS_ADDR
|
||||
$taskOutput=Join-Path $taskRoot $OutputDirectory
|
||||
New-Item -ItemType Directory -Force -Path $taskOutput | Out-Null
|
||||
function Run-Migrate([string[]]$MigrationArgs){
|
||||
docker run --rm --pull=never --network $taskNetwork --mount "type=bind,source=$taskRoot/migrations,target=/migrations,readonly" migrate/migrate:v4.18.3 -path=/migrations "-database=postgres://voice:voice_test_only@$taskPostgres`:5432/initial_memory_template?sslmode=disable" @MigrationArgs
|
||||
}
|
||||
try{
|
||||
docker network create $taskNetwork | Out-Null
|
||||
docker run -d --pull=never --name $taskPostgres --network $taskNetwork --tmpfs /var/lib/postgresql/data -e POSTGRES_USER=voice -e POSTGRES_PASSWORD=voice_test_only -e POSTGRES_DB=initial_memory_template -p '127.0.0.1::5432' pgvector/pgvector:pg16 | Out-Null
|
||||
docker run -d --pull=never --name $taskRedis --network $taskNetwork -p '127.0.0.1::6379' redis:7-alpine | Out-Null
|
||||
$taskReady=$false
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
for($taskAttempt=0;$taskAttempt -lt 40;$taskAttempt++){
|
||||
docker exec $taskPostgres pg_isready -U voice -d initial_memory_template *> $null
|
||||
if($LASTEXITCODE -eq 0){$taskReady=$true;break}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
if(!$taskReady){throw 'Isolated PostgreSQL did not become ready'}
|
||||
Run-Migrate @('up') *> (Join-Path $taskOutput 'migrate.log')
|
||||
docker cp test/fixtures/local_seed.sql "${taskPostgres}:/tmp/local_seed.sql"
|
||||
docker exec $taskPostgres psql -U voice -d initial_memory_template -v ON_ERROR_STOP=1 -f /tmp/local_seed.sql *> (Join-Path $taskOutput 'seed.log')
|
||||
$taskPgBinding=docker port $taskPostgres 5432/tcp
|
||||
$taskRedisBinding=docker port $taskRedis 6379/tcp
|
||||
if($taskPgBinding -notmatch '^127\.0\.0\.1:(\d+)$'){throw 'Unexpected test database binding'}
|
||||
$taskPgPort=$Matches[1]
|
||||
if($taskRedisBinding -notmatch '^127\.0\.0\.1:(\d+)$'){throw 'Unexpected test Redis binding'}
|
||||
$taskRedisPort=$Matches[1]
|
||||
$taskSummary=@()
|
||||
foreach($taskPackage in @('saas','onboarding','httpapi')){
|
||||
$taskDatabase="initial_memory_test_$taskPackage"
|
||||
docker exec $taskPostgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskDatabase TEMPLATE initial_memory_template;" | Out-Null
|
||||
$env:TEST_DATABASE_URL="postgres://voice:voice_test_only@127.0.0.1:$taskPgPort/$taskDatabase`?sslmode=disable"
|
||||
$env:TEST_REDIS_ADDR="127.0.0.1:$taskRedisPort"
|
||||
$taskLog=Join-Path $taskOutput "integration-$taskPackage.jsonl"
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
go test -count=1 -p 1 -json "./internal/$taskPackage" *> $taskLog
|
||||
$taskTestExit=$LASTEXITCODE
|
||||
$PSNativeCommandUseErrorActionPreference=$true
|
||||
$taskEvents=Get-Content -LiteralPath $taskLog | ForEach-Object {try{$_|ConvertFrom-Json}catch{}}
|
||||
$taskSummary+=@{package=$taskPackage;passed=@($taskEvents|Where-Object{$_.Action -eq 'pass' -and $_.Test}).Count;skipped=@($taskEvents|Where-Object{$_.Action -eq 'skip' -and $_.Test}).Count;exit_code=$taskTestExit}
|
||||
if($taskTestExit -ne 0){Get-Content -LiteralPath $taskLog -Tail 90;throw "Integration tests failed: $taskPackage"}
|
||||
}
|
||||
$taskSummary|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $taskOutput 'results.json')
|
||||
$taskSummary|ConvertTo-Json
|
||||
}finally{
|
||||
$env:TEST_DATABASE_URL=$taskPreviousDatabase
|
||||
$env:TEST_REDIS_ADDR=$taskPreviousRedis
|
||||
$PSNativeCommandUseErrorActionPreference=$false
|
||||
# Names are generated by this invocation; never touch Compose or other services.
|
||||
docker rm -f $taskPostgres $taskRedis *> $null
|
||||
docker network rm $taskNetwork *> $null
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
param([string[]]$Packages = @('onboarding','devicereset','opsdevice','hardwareprovision','idempotency','httpapi','orchestration','saas','outbox','privacy'))
|
||||
param(
|
||||
[string[]]$Packages = @('onboarding','devicereset','opsdevice','hardwareprovision','idempotency','httpapi','orchestration','saas','outbox','privacy'),
|
||||
[switch]$VerboseTests,
|
||||
[string]$OutputDirectory = '.tmp-onboarding'
|
||||
)
|
||||
$ErrorActionPreference='Continue'
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
$taskTemplate='codex_onboarding_template_'+[Guid]::NewGuid().ToString('N').Substring(0,12)
|
||||
$taskDatabases=[System.Collections.Generic.List[string]]::new()
|
||||
New-Item -ItemType Directory -Force -Path '.tmp-onboarding' | Out-Null
|
||||
$taskPreviousDatabase = $env:TEST_DATABASE_URL
|
||||
$taskPreviousRedis = $env:TEST_REDIS_ADDR
|
||||
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
|
||||
try {
|
||||
docker compose exec -T postgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskTemplate" | Out-Null
|
||||
if($LASTEXITCODE -ne 0){throw 'Test database creation failed'}
|
||||
$taskDatabases.Add($taskTemplate)
|
||||
docker compose run --rm --no-deps migrate -path=/migrations "-database=postgres://voice:voice_dev_only@postgres:5432/$taskTemplate`?sslmode=disable" up 2>&1 | Out-File '.tmp-onboarding/migration-validation.log'
|
||||
docker compose run --rm --no-deps migrate -path=/migrations "-database=postgres://voice:voice_dev_only@postgres:5432/$taskTemplate`?sslmode=disable" up 2>&1 | Out-File (Join-Path $OutputDirectory 'migration-validation.log')
|
||||
if($LASTEXITCODE -ne 0){throw 'Migration validation failed'}
|
||||
docker compose run --rm --no-deps -e PGPASSWORD=voice_dev_only seed psql -h postgres -U voice -d $taskTemplate -v ON_ERROR_STOP=1 -f /fixtures/local_seed.sql 2>&1 | Out-File '.tmp-onboarding/seed-validation.log'
|
||||
docker compose run --rm --no-deps -e PGPASSWORD=voice_dev_only seed psql -h postgres -U voice -d $taskTemplate -v ON_ERROR_STOP=1 -f /fixtures/local_seed.sql 2>&1 | Out-File (Join-Path $OutputDirectory 'seed-validation.log')
|
||||
if($LASTEXITCODE -ne 0){throw 'Seed validation failed'}
|
||||
foreach($taskPackage in $Packages) {
|
||||
if($taskPackage -notmatch '^[a-z]+$'){throw 'Invalid package'}
|
||||
@@ -20,11 +26,15 @@ try {
|
||||
$taskDatabases.Add($taskDatabase)
|
||||
$env:TEST_DATABASE_URL="postgres://voice:voice_dev_only@127.0.0.1:55432/$taskDatabase`?sslmode=disable"
|
||||
$env:TEST_REDIS_ADDR='127.0.0.1:56379'
|
||||
go test -count=1 -p 1 "./internal/$taskPackage" 2>&1 | Tee-Object ".tmp-onboarding/integration-$taskPackage.log"
|
||||
$taskGoArgs = @('test', '-count=1', '-p', '1')
|
||||
if ($VerboseTests) { $taskGoArgs += '-v' }
|
||||
$taskGoArgs += "./internal/$taskPackage"
|
||||
go @taskGoArgs 2>&1 | Tee-Object (Join-Path $OutputDirectory "integration-$taskPackage.log")
|
||||
if($LASTEXITCODE -ne 0){throw "Integration failed: $taskPackage"}
|
||||
}
|
||||
} finally {
|
||||
Remove-Item Env:TEST_DATABASE_URL -ErrorAction SilentlyContinue
|
||||
$env:TEST_DATABASE_URL = $taskPreviousDatabase
|
||||
$env:TEST_REDIS_ADDR = $taskPreviousRedis
|
||||
foreach($taskDatabase in $taskDatabases) {
|
||||
# Only databases created by this invocation can be removed.
|
||||
if($taskDatabase -match '^codex_onboarding_(template|test)_[0-9a-f]{12}$'){
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Repeatable local acceptance checks. No application deployment or device flashing.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$taskRoot = Split-Path -Parent $PSScriptRoot
|
||||
$taskPreviousLocation = Get-Location
|
||||
$taskPreviousPlaywright = $env:PLAYWRIGHT_REQUIRE_FROM
|
||||
$taskPreviousDatabase = $env:TEST_DATABASE_URL
|
||||
$taskPreviousRedis = $env:TEST_REDIS_ADDR
|
||||
$taskReport = Join-Path $taskRoot ('.design-evidence/call-memory-acceptance/' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff'))
|
||||
$taskSteps = [System.Collections.Generic.List[object]]::new()
|
||||
$taskStarted = (Get-Date).ToString('o')
|
||||
$taskFailure = $null
|
||||
$taskPassedCount = 0
|
||||
$taskUnverified = @()
|
||||
|
||||
function Invoke-AcceptanceCheck {
|
||||
param([string]$Name, [string]$Command, [string[]]$Arguments)
|
||||
Write-Host "[$Name] Running..."
|
||||
$taskLog = Join-Path $taskReport "$Name.log"
|
||||
$taskStepStart = Get-Date
|
||||
# Native stderr (e.g. build warnings) is logged; exit status decides success.
|
||||
$taskOldPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command @Arguments *> $taskLog
|
||||
$taskCode = $LASTEXITCODE
|
||||
} finally { $ErrorActionPreference = $taskOldPreference }
|
||||
$taskSteps.Add([ordered]@{
|
||||
name = $Name; exit_code = $taskCode; log = $taskLog
|
||||
seconds = [math]::Round(((Get-Date) - $taskStepStart).TotalSeconds, 2)
|
||||
})
|
||||
if ($taskCode -ne 0) {
|
||||
Get-Content -LiteralPath $taskLog -Tail 35
|
||||
throw "$Name failed; see $taskLog"
|
||||
}
|
||||
Write-Host "[$Name] PASS"
|
||||
}
|
||||
|
||||
try {
|
||||
Set-Location -LiteralPath $taskRoot
|
||||
New-Item -ItemType Directory -Force -Path $taskReport | Out-Null
|
||||
foreach ($taskTool in @('git', 'docker', 'go', 'node', 'npm.cmd', 'powershell.exe')) {
|
||||
Get-Command $taskTool -ErrorAction Stop | Out-Null
|
||||
}
|
||||
if (-not (Test-Path 'web/node_modules/vite')) {
|
||||
throw 'Web dependencies missing. Run npm ci --prefix web first.'
|
||||
}
|
||||
if (-not $env:PLAYWRIGHT_REQUIRE_FROM) {
|
||||
$taskBundledPlaywright = Join-Path $env:USERPROFILE '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/package.json'
|
||||
if (Test-Path -LiteralPath $taskBundledPlaywright) {
|
||||
$env:PLAYWRIGHT_REQUIRE_FROM = $taskBundledPlaywright
|
||||
}
|
||||
}
|
||||
# Record hashes for tracked and untracked source files, including local changes.
|
||||
git rev-parse HEAD | Set-Content (Join-Path $taskReport 'base-commit.txt')
|
||||
git -c core.quotepath=false status --short | Set-Content -Encoding UTF8 (Join-Path $taskReport 'worktree-status.txt')
|
||||
$taskSourceHashes = @(foreach ($taskRelative in (git -c core.quotepath=false ls-files --cached --others --exclude-standard)) {
|
||||
if (Test-Path -LiteralPath $taskRelative -PathType Leaf) {
|
||||
[ordered]@{ path = $taskRelative; sha256 = (Get-FileHash -LiteralPath $taskRelative -Algorithm SHA256).Hash }
|
||||
}
|
||||
})
|
||||
ConvertTo-Json -Depth 4 -InputObject $taskSourceHashes | Set-Content -Encoding UTF8 (Join-Path $taskReport 'source-sha256.json')
|
||||
# Ignore any inherited test DB setting; integration tests create their own databases.
|
||||
$env:TEST_DATABASE_URL = $null
|
||||
$env:TEST_REDIS_ADDR = $null
|
||||
Invoke-AcceptanceCheck 'dependencies' 'docker' @('compose', 'up', '-d', 'postgres', 'redis')
|
||||
Invoke-AcceptanceCheck 'go-tests' 'go' @('test', '-count=1', './...')
|
||||
Invoke-AcceptanceCheck 'go-vet' 'go' @('vet', './...')
|
||||
$taskIntegration = Join-Path $taskRoot 'scripts/test-onboarding-integration.ps1'
|
||||
# A child shell gives thrown integration errors an unambiguous nonzero exit code.
|
||||
$taskQuotedScript = $taskIntegration.Replace("'", "''")
|
||||
$taskQuotedOutput = (Join-Path $taskReport 'database').Replace("'", "''")
|
||||
$taskIntegrationCommand = "& '$taskQuotedScript' -VerboseTests -OutputDirectory '$taskQuotedOutput' -Packages billing,callmemory,onboarding,saas,extraction,retrieval,memorytool,privacy,orchestration,vikingmemory"
|
||||
Invoke-AcceptanceCheck 'database-integration' 'powershell.exe' @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $taskIntegrationCommand)
|
||||
$taskDbLogs = Get-ChildItem -LiteralPath (Join-Path $taskReport 'database') -Filter 'integration-*.log'
|
||||
$taskPassed = @($taskDbLogs | Select-String -Pattern '^--- PASS:')
|
||||
$taskSkipped = @($taskDbLogs | Select-String -Pattern '^\s*--- SKIP:')
|
||||
$taskPassedCount = $taskPassed.Count
|
||||
$taskUnverified = @($taskSkipped | ForEach-Object { $_.Line })
|
||||
$taskPassed | ForEach-Object { $_.Line } | Set-Content -Encoding UTF8 (Join-Path $taskReport 'database-passed-tests.txt')
|
||||
$taskSkipped | ForEach-Object { $_.Line } | Set-Content -Encoding UTF8 (Join-Path $taskReport 'database-skipped-tests.txt')
|
||||
# This existing optional test needs a separately configured real content model.
|
||||
# Keep it explicitly unverified; all database-required tests must actually run.
|
||||
$taskUnexpectedSkips = @($taskSkipped | Where-Object { $_.Line -notmatch '^--- SKIP: TestLocalModelContentValueDecisions \(' })
|
||||
if ($taskDbLogs.Count -ne 10 -or $taskPassed.Count -eq 0 -or $taskUnexpectedSkips.Count -gt 0) {
|
||||
throw 'Database verification incomplete: a package is missing or a required test was skipped. Inspect database logs.'
|
||||
}
|
||||
foreach ($taskSkippedTest in $taskUnverified) { Write-Host "UNVERIFIED: $taskSkippedTest" }
|
||||
Invoke-AcceptanceCheck 'web-build' 'npm.cmd' @('run', 'build', '--prefix', 'web')
|
||||
Invoke-AcceptanceCheck 'browser-component' 'node' @('test/e2e/memory-workspace.mjs')
|
||||
Copy-Item -LiteralPath (Join-Path $taskRoot '.design-evidence/call-memory') -Destination (Join-Path $taskReport 'browser') -Recurse
|
||||
Invoke-AcceptanceCheck 'firmware-node-tests' 'node' @('--test', 'firmware/echoear-platform-agent/tests/setup-portal.test.cjs', 'firmware/echoear-platform-agent/tests/private-sdk-logs.test.cjs')
|
||||
} catch {
|
||||
$taskFailure = $_.Exception.Message
|
||||
Write-Host "FAILED: $taskFailure"
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $taskReport) {
|
||||
[ordered]@{
|
||||
status = $(if ($taskFailure) { 'FAILED' } else { 'PASSED_LOCAL_CHECKS' })
|
||||
started_at = $taskStarted; finished_at = (Get-Date).ToString('o')
|
||||
error = $taskFailure; steps = @($taskSteps.ToArray())
|
||||
integration_package_test_passes = $taskPassedCount
|
||||
unverified_tests = $taskUnverified
|
||||
boundary = 'Real isolated PostgreSQL tests; browser uses synthetic APIs. Not hardware/provider/end-to-end acceptance. Firmware compile is not run by this script.'
|
||||
} | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 (Join-Path $taskReport 'summary.json')
|
||||
Write-Host "Evidence: $taskReport"
|
||||
}
|
||||
$env:PLAYWRIGHT_REQUIRE_FROM = $taskPreviousPlaywright
|
||||
$env:TEST_DATABASE_URL = $taskPreviousDatabase
|
||||
$env:TEST_REDIS_ADDR = $taskPreviousRedis
|
||||
Set-Location -LiteralPath $taskPreviousLocation
|
||||
}
|
||||
if ($taskFailure) { exit 1 }
|
||||
Write-Host 'PASSED_LOCAL_CHECKS (not full hardware/provider acceptance)'
|
||||
@@ -31,7 +31,8 @@ try {
|
||||
$manifestPath = Join-Path $payload[0].FullName 'deploy-manifest.json'
|
||||
$checksumsPath = Join-Path $payload[0].FullName 'SHA256SUMS'
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne 58 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
|
||||
$expectedMigration = (Get-ChildItem -LiteralPath (Join-Path $payload[0].FullName 'migrations') -Filter '*.up.sql' | ForEach-Object { [int]($_.Name -split '_')[0] } | Measure-Object -Maximum).Maximum
|
||||
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne $expectedMigration -or $expectedMigration -le 0 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
|
||||
throw 'Deployment manifest metadata is invalid'
|
||||
}
|
||||
$verifiedFiles = 0
|
||||
|
||||
Reference in New Issue
Block a user