@tasksai/install 0.1.38 → 0.1.40

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.
Files changed (74) hide show
  1. package/README.md +34 -0
  2. package/bootstrap/Install RealtorTasksAI.command +37 -0
  3. package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
  4. package/package.json +14 -3
  5. package/runtime/document_renderer.py +882 -0
  6. package/runtime/server.py +55 -581
  7. package/runtime/software_use.py +39 -0
  8. package/runtime/workflow-requirements.txt +5 -0
  9. package/runtime/workflows/__init__.py +0 -0
  10. package/runtime/workflows/account_snapshot.py +33 -0
  11. package/runtime/workflows/attachments.py +45 -0
  12. package/runtime/workflows/authority_guides.py +81 -0
  13. package/runtime/workflows/catalog.py +51 -0
  14. package/runtime/workflows/catalog_app.py +174 -0
  15. package/runtime/workflows/catalog_generation.py +186 -0
  16. package/runtime/workflows/catalog_output.py +312 -0
  17. package/runtime/workflows/catalog_suggestions.py +51 -0
  18. package/runtime/workflows/catalog_workspace.py +152 -0
  19. package/runtime/workflows/cli.py +66 -0
  20. package/runtime/workflows/document_answers.py +96 -0
  21. package/runtime/workflows/document_selection.py +50 -0
  22. package/runtime/workflows/documents.py +243 -0
  23. package/runtime/workflows/embedded/catalog.html +83 -0
  24. package/runtime/workflows/embedded/offer-review.html +516 -0
  25. package/runtime/workflows/embedded/preview.html +36 -0
  26. package/runtime/workflows/embedded_actions.py +96 -0
  27. package/runtime/workflows/embedded_demo.py +424 -0
  28. package/runtime/workflows/folders.py +120 -0
  29. package/runtime/workflows/gateway.py +157 -0
  30. package/runtime/workflows/generation_lock.py +26 -0
  31. package/runtime/workflows/launcher.py +61 -0
  32. package/runtime/workflows/licensed_delivery.py +46 -0
  33. package/runtime/workflows/mcp_dev.py +52 -0
  34. package/runtime/workflows/meeting_plan.py +92 -0
  35. package/runtime/workflows/model_client.py +26 -0
  36. package/runtime/workflows/numeric_consistency.py +72 -0
  37. package/runtime/workflows/public_source_fetch.py +66 -0
  38. package/runtime/workflows/realtor/__init__.py +0 -0
  39. package/runtime/workflows/realtor/adapter.py +179 -0
  40. package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
  41. package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
  42. package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
  43. package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
  44. package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
  45. package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
  46. package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
  47. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
  48. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
  49. package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
  50. package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
  51. package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
  52. package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
  53. package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
  54. package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
  55. package/runtime/workflows/realtor-release-registry.json +705 -0
  56. package/runtime/workflows/released_catalog.py +91 -0
  57. package/runtime/workflows/source_capture.py +82 -0
  58. package/runtime/workflows/store.py +492 -0
  59. package/runtime/workflows/table_calculations.py +132 -0
  60. package/runtime/workflows/template.py +65 -0
  61. package/runtime/workflows/workspace_launch.py +76 -0
  62. package/src/index.js +217 -118
  63. package/src/managed-python.js +63 -0
  64. package/src/operation-lock.js +22 -0
  65. package/src/prepared-update.js +86 -0
  66. package/src/private-workflow.js +116 -0
  67. package/src/python-runtime.js +50 -0
  68. package/src/recover-installation.js +30 -0
  69. package/src/recovery-lock.js +30 -0
  70. package/src/software-hash.js +24 -0
  71. package/src/software-use.js +32 -0
  72. package/src/update-journal.js +24 -0
  73. package/src/update-recovery.js +72 -0
  74. package/src/workspace-runtime.js +45 -0
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {spawnSync} from 'node:child_process';
5
+
6
+ export const PYTHON_RELEASE='3.12.14+20260901';
7
+ export function pythonArchiveTool(platform=process.platform,systemRoot=process.env.SystemRoot){
8
+ // Git's GNU tar can interpret Windows drive-letter paths as remote hosts.
9
+ // Select the OS archive reader instead of whichever tar appears on PATH.
10
+ return platform==='win32'?path.win32.join(systemRoot||'C:\\Windows','System32','tar.exe'):'/usr/bin/tar';
11
+ }
12
+ const builds={
13
+ 'darwin-arm64':['aarch64-apple-darwin','3ee3ee547cedfeb7c2b16b2b7156039f7b470bb8f857e226fd3d2eb11db83c76'],
14
+ 'darwin-x64':['x86_64-apple-darwin','2e31b23f3f1319f707d0e620b48847a0046577541d357276821f9f1b5492e0ba'],
15
+ 'win32-arm64':['aarch64-pc-windows-msvc','4e852236277eb8f7105cbe0f5adf45592f521af238bc0f700c351856e2c2e41a'],
16
+ 'win32-x64':['x86_64-pc-windows-msvc','e90c1b6419da3bd812dd73bb3de40287a21abf153438147639ec5e20375ea93f'],
17
+ };
18
+
19
+ export function managedPythonLocation(installDir,{platform=process.platform,arch=process.arch}={}){
20
+ const folder=path.join(installDir,'managed-python',`cpython-${PYTHON_RELEASE}-${platform}-${arch}`);
21
+ return {folder,executable:path.join(folder,'python',...(platform==='win32'?['python.exe']:['bin','python3.12']))};
22
+ }
23
+
24
+ export function probeManagedPython(executable,{run=spawnSync}={}){
25
+ const result=run(executable,['-I','-c','import json,sys,sqlite3,ssl,venv,pip; print(json.dumps(list(sys.version_info[:3])))'],{encoding:'utf8',timeout:10000,windowsHide:true,maxBuffer:16384});
26
+ try{return !result.error&&result.status===0&&JSON.stringify(JSON.parse(result.stdout))==='[3,12,14]';}catch{return false;}
27
+ }
28
+
29
+ export async function installManagedPython(installDir,{platform=process.platform,arch=process.arch,fetchImpl=fetch,run=spawnSync}={}){
30
+ const build=builds[`${platform}-${arch}`];
31
+ if(!build)throw Error('Automatic Python setup is supported on Mac and Windows. Install Python 3.10 or newer to continue on this platform.');
32
+ const {folder,executable}=managedPythonLocation(installDir,{platform,arch});
33
+ if(probeManagedPython(executable,{run}))return executable;
34
+ try{await fs.lstat(folder);throw Error('The TasksAI Python folder exists but could not be validated. It was preserved; repair is required before continuing.');}catch(error){if(error.code!=='ENOENT')throw error;}
35
+ await fs.mkdir(path.dirname(folder),{recursive:true});
36
+ const staging=await fs.mkdtemp(path.join(path.dirname(folder),'.prepare-'));
37
+ try{
38
+ const url=`https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-${encodeURIComponent(PYTHON_RELEASE)}-${build[0]}-install_only.tar.gz`;
39
+ const response=await fetchImpl(url,{signal:AbortSignal.timeout(120000)});
40
+ if(!response.ok)throw Error('The Python download failed. Existing TasksAI files are unchanged.');
41
+ if(response.url&& !['github.com','release-assets.githubusercontent.com'].includes(new URL(response.url).hostname))throw Error('Unexpected Python download destination.');
42
+ const chunks=[];let size=0;
43
+ for await(const chunk of response.body){size+=chunk.length;if(size>100*1024*1024)throw Error('Python download exceeds its size limit.');chunks.push(chunk);}
44
+ const bytes=Buffer.concat(chunks);
45
+ if(createHash('sha256').update(bytes).digest('hex')!==build[1])throw Error('Python download verification failed. Nothing was installed.');
46
+ const archive=path.join(staging,'python.tar.gz');await fs.writeFile(archive,bytes);
47
+ const options={encoding:'utf8',timeout:60000,windowsHide:true,maxBuffer:16*1024*1024};
48
+ const tar=pythonArchiveTool(platform);
49
+ const listing=run(tar,['-tzf',archive],options);
50
+ if(listing.error||listing.status!==0)throw Error('The Python archive could not be inspected.');
51
+ const entries=listing.stdout.split(/\r?\n/).filter(Boolean);
52
+ if(!entries.length||entries.some(name=>!name.startsWith('python/')||name.includes('\\')||name.split('/').includes('..')))throw Error('The Python archive contains an unexpected path.');
53
+ const extracted=run(tar,['-xzf',archive,'-C',staging],options);
54
+ if(extracted.error||extracted.status!==0)throw Error('The Python archive could not be extracted.');
55
+ const stagedExecutable=path.join(staging,path.relative(folder,executable));
56
+ if(!probeManagedPython(stagedExecutable,{run}))throw Error('The downloaded Python did not pass its local checks.');
57
+ await fs.unlink(archive);
58
+ await fs.writeFile(path.join(staging,'tasksai-python.json'),JSON.stringify({version:PYTHON_RELEASE,url,sha256:build[1]},null,2)+'\n');
59
+ await fs.rename(staging,folder);
60
+ if(!probeManagedPython(executable,{run}))throw Error('The installed Python could not start. Its files were retained for repair.');
61
+ return executable;
62
+ }finally{await fs.rm(staging,{recursive:true,force:true});}
63
+ }
@@ -0,0 +1,22 @@
1
+ import {promises as fs} from 'node:fs';
2
+
3
+ // Never remove another process's lock, or assume that age means it is stale.
4
+ export async function withLock(lockPath, callback) {
5
+ let handle;
6
+ try {
7
+ handle = await fs.open(lockPath, 'wx', 0o600);
8
+ } catch (error) {
9
+ if (error.code === 'EEXIST') {
10
+ throw new Error(`Another TasksAI installation operation is running, or a previous operation was interrupted (${lockPath}). Let the running operation finish. If it was interrupted, preserve the installation for recovery before retrying.`);
11
+ }
12
+ throw error;
13
+ }
14
+ try {
15
+ await handle.writeFile(JSON.stringify({pid: process.pid, startedAt: new Date().toISOString()}) + '\n');
16
+ await handle.sync();
17
+ return await callback();
18
+ } finally {
19
+ await handle.close();
20
+ await fs.unlink(lockPath);
21
+ }
22
+ }
@@ -0,0 +1,86 @@
1
+ import path from 'node:path';
2
+ import {promises as fs} from 'node:fs';
3
+ import {writeUpdateJournal} from './update-journal.js';
4
+ import {softwareHash} from './software-hash.js';
5
+ import {withSoftwareStopped} from './software-use.js';
6
+
7
+ async function exists(target){try{return await fs.lstat(target);}catch(error){if(error.code==='ENOENT')return null;throw error;}}
8
+
9
+ // Prepare all replacement software before touching the installed copy. This
10
+ // restores ordinary activation failures; crash recovery is a separate gate.
11
+ export async function preparedUpdate(installDir,options){
12
+ return withSoftwareStopped(installDir,assertHeld=>prepareSoftware(installDir,{...options,assertHeld}));
13
+ }
14
+
15
+ async function prepareSoftware(installDir,{prepare,installDependencies,validate=async()=>{},skipDependencies=false,rename=fs.rename,assertHeld}){
16
+ // Even if an interrupted operation's lock was cleared, never replace software
17
+ // while its unfinished recovery copy remains. Successful backups are retained.
18
+ for(const entry of await fs.readdir(installDir,{withFileTypes:true})){
19
+ if(!entry.name.startsWith('.software-update-'))continue;
20
+ const prior=path.join(installDir,entry.name);
21
+ let completed=false;
22
+ if(entry.isDirectory()&&!entry.isSymbolicLink()){
23
+ const statePath=path.join(prior,'state.json');
24
+ const stat=await exists(statePath);
25
+ if(stat?.isFile()&&!stat.isSymbolicLink()&&stat.size<=4096){
26
+ try{completed=['activated','recovered'].includes(JSON.parse(await fs.readFile(statePath,'utf8')).status);}catch{}
27
+ }
28
+ }
29
+ if(!completed)throw Error('An unfinished software update requires recovery. Preserve '+prior+' and the installed files before retrying.');
30
+ }
31
+ const transaction=await fs.mkdtemp(path.join(installDir,'.software-update-'));
32
+ await fs.chmod(transaction,0o700);
33
+ const prepared=path.join(transaction,'prepared'),previous=path.join(transaction,'previous');
34
+ await fs.mkdir(prepared);await fs.mkdir(previous);
35
+ const names=[...(skipDependencies?[]:['python']),'runtime','agent-install.json','vertical.json','.env'];
36
+ const moved=[];let activationStarted=false;
37
+ try{
38
+ for(const name of names){const stat=await exists(path.join(installDir,name));if(stat?.isSymbolicLink())throw Error('Installed software paths cannot be symbolic links');}
39
+ const runtime=path.join(prepared,'runtime');
40
+ if(await exists(path.join(installDir,'runtime')))await fs.cp(path.join(installDir,'runtime'),runtime,{recursive:true,dereference:false,
41
+ filter:async source=>{if((await fs.lstat(source)).isSymbolicLink())throw Error('Installed runtime files cannot be symbolic links');return true;}});
42
+ else await fs.mkdir(runtime);
43
+ await prepare(prepared,runtime);
44
+ if(!skipDependencies)await installDependencies(runtime,path.join(prepared,'python'));
45
+ await validate(runtime,path.join(prepared,'python'));
46
+ for(const name of names)if(!(await exists(path.join(prepared,name))))throw Error('Prepared update is missing '+name);
47
+ const assets=[];
48
+ for(const name of names){
49
+ const beforeHash=await softwareHash(path.join(installDir,name));
50
+ assets.push({name,hadPrevious:beforeHash!==null,beforeHash,afterHash:await softwareHash(path.join(prepared,name)),phase:'pending'});
51
+ }
52
+ const state={format:2,status:'prepared',names,assets};
53
+ await writeUpdateJournal(transaction,state);
54
+ assertHeld();
55
+ activationStarted=true;
56
+ for(const asset of assets){
57
+ assertHeld();
58
+ const {name}=asset;
59
+ const target=path.join(installDir,name),backup=path.join(previous,name);
60
+ const item={name,hadPrevious:asset.hadPrevious,activated:false};moved.push(item);
61
+ if(item.hadPrevious){
62
+ await rename(target,backup);
63
+ asset.phase='original_moved';await writeUpdateJournal(transaction,state);
64
+ }
65
+ await rename(path.join(prepared,name),target);item.activated=true;
66
+ asset.phase='replacement_moved';await writeUpdateJournal(transaction,state);
67
+ }
68
+ state.status='activated';await writeUpdateJournal(transaction,state);
69
+ return {previousSoftware:previous};
70
+ }catch(error){
71
+ if(activationStarted)assertHeld();
72
+ let restoreError;
73
+ for(const item of [...moved].reverse()){
74
+ const target=path.join(installDir,item.name),backup=path.join(previous,item.name);
75
+ try{
76
+ if(item.activated)await fs.rename(target,path.join(prepared,item.name));
77
+ if(await exists(backup))await fs.rename(backup,target);
78
+ }catch(failure){restoreError=failure;}
79
+ }
80
+ if(restoreError)throw Error('Software update stopped and automatic software restoration failed. Preserve '+transaction+' for recovery. '+restoreError.message,{cause:error});
81
+ // All data here is a prepared software copy; project workspaces are never
82
+ // included. Keep successful previous software, discard failed preparations.
83
+ await fs.rm(transaction,{recursive:true,force:true});
84
+ throw Error(activationStarted?'Software update failed; previous software was restored. '+error.message:'Software preparation failed; installed software was not replaced. '+error.message,{cause:error});
85
+ }
86
+ }
@@ -0,0 +1,116 @@
1
+ // Explicit private-development route in the shared launcher. No license/billing API imports.
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import {spawnSync} from 'node:child_process';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {createHash, randomUUID} from 'node:crypto';
8
+
9
+ const runtime=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../runtime');
10
+ function run(command,args,options={}){
11
+ const result=spawnSync(command,args,{encoding:'utf8',...options});
12
+ if(result.error||result.status!==0)throw new Error(`Private workflow setup failed: ${result.error?.message||result.stderr?.slice(-3000)||'command failed'}`);
13
+ return result.stdout;
14
+ }
15
+ const q=value=>JSON.stringify(value); // TOML strings only, never shell interpolation.
16
+ const sh=value=>"'"+value.replaceAll("'","'\\''")+"'";
17
+ export async function installPrivateWorkflow(argv){
18
+ if(process.platform!=='darwin')throw new Error('This private trial has only been qualified for Mac.');
19
+ const options={};
20
+ for(let i=0;i<argv.length;i++){
21
+ if(argv[i]==='--enable-development')options.enabled=true;
22
+ else if(argv[i]==='--update')options.update=true;
23
+ else if(['--install-dir','--python'].includes(argv[i])){
24
+ const key=argv[i].slice(2);if(!argv[i+1]||argv[i+1].startsWith('--'))throw new Error(`Missing ${argv[i]} value`);
25
+ options[key]=argv[++i];
26
+ }else throw new Error(`Unsupported private workflow option: ${argv[i]}`);
27
+ }
28
+ if(!options.enabled)throw new Error('Private workflow requires explicit --enable-development.');
29
+ const root=path.resolve(options['install-dir']||path.join(os.homedir(),'TasksAI Private Trial'));
30
+ if(options.update)return updatePrivateWorkflow(root);
31
+ // Refuse overwrite; new installs cannot replace a workspace or a customer installation.
32
+ await fs.mkdir(root,{recursive:false,mode:0o700});
33
+ const marker=path.join(root,'INSTALLATION-INCOMPLETE.txt');
34
+ await fs.writeFile(marker,'Installation is incomplete. Retain this folder for diagnosis. No client profile was changed.\n',{mode:0o600});
35
+ const python=options.python||'python3';
36
+ run(python,['-c','import sys; assert (3,11)<=sys.version_info[:2]<(3,15), "Python 3.11–3.14 required"']);
37
+ const installed=path.join(root,'runtime');await fs.mkdir(installed,{mode:0o700});
38
+ await fs.cp(path.join(runtime,'workflows'),path.join(installed,'workflows'),{recursive:true,filter:s=>!s.includes('__pycache__')&&!s.endsWith('.pyc')});
39
+ await fs.copyFile(path.join(runtime,'document_renderer.py'),path.join(installed,'document_renderer.py'));
40
+ const requirements=path.join(root,'requirements.txt');
41
+ await fs.copyFile(path.join(runtime,'workflow-requirements.txt'),requirements);
42
+ // A clean environment excludes compiled packages from an existing customer installation.
43
+ run(python,['-m','venv',path.join(root,'python')]);
44
+ const executable=path.join(root,'python/bin/python');
45
+ console.log('Installing the private document and spreadsheet components…');
46
+ run(executable,['-m','pip','install','--disable-pip-version-check','-r',requirements],{timeout:240000});
47
+ const workspace=path.join(root,'Saved jobs'),inbox=path.join(root,'Inbox');
48
+ await fs.mkdir(workspace,{mode:0o700});await fs.mkdir(inbox,{mode:0o700});
49
+ const server={command:executable,args:['-m','workflows.mcp_dev','--enable-development','--workspace',workspace,'--owner','private-mac-trial','--inbox',inbox],cwd:installed,env:{PYTHONPATH:installed},startup_timeout_sec:30,tool_timeout_sec:180,default_tools_approval_mode:'approve'};
50
+ const lines=['[mcp_servers.tasksai_workflow_dev]',...Object.entries(server).filter(([k])=>k!=='env').map(([k,v])=>`${k} = ${q(v)}`),'[mcp_servers.tasksai_workflow_dev.env]',`PYTHONPATH = ${q(installed)}`,''];
51
+ await fs.writeFile(path.join(root,'connection.toml'),lines.join('\n'),{mode:0o600});
52
+ const overrides=[];
53
+ for(const [k,v] of Object.entries(server)){
54
+ if(k==='env')overrides.push('-c',`mcp_servers.tasksai_workflow_dev.env.PYTHONPATH=${q(installed)}`);
55
+ else overrides.push('-c',`mcp_servers.tasksai_workflow_dev.${k}=${q(v)}`);
56
+ }
57
+ let codex=process.env.TASKSAI_CODEX_EXECUTABLE;
58
+ if(!codex){
59
+ const app='/Applications/ChatGPT.app/Contents/Resources/codex';
60
+ try{await fs.access(app);codex=app;}catch{codex='codex';}
61
+ }
62
+ const login=spawnSync(codex,['login','status'],{encoding:'utf8'});
63
+ if(login.status!==0||!`${login.stdout}${login.stderr}`.includes('Logged in using ChatGPT'))throw new Error('Sign into Codex with ChatGPT, then retry in a new installation folder.');
64
+ // Preserve only model selection; every conversation uses isolated Codex exec.
65
+ const model=run(python,['-c','import pathlib,tomllib,json; p=pathlib.Path.home()/".codex/config.toml"; d=tomllib.loads(p.read_text()) if p.exists() else {}; print(json.dumps({k:d[k] for k in ("model","model_reasoning_effort") if k in d}))']);
66
+ await fs.writeFile(path.join(root,'model-settings.json'),model,{mode:0o600});
67
+ const launch=['#!/bin/zsh','set -e',`cd ${sh(installed)}`,`exec ${sh(executable)} -m workflows.launcher --root ${sh(root)} --codex ${sh(codex)}`,''];
68
+ await fs.writeFile(path.join(root,'Start TasksAI.command'),launch.join('\n'),{mode:0o700});
69
+ run(executable,['-c','from workflows.gateway import Gateway, TOOLS; from workflows.store import JobStore; import docx,xlsxwriter,pypdf; print(len(TOOLS))'],{cwd:installed,env:{...process.env,PYTHONPATH:installed}});
70
+ const files={};
71
+ async function hashTree(dir){for(const item of await fs.readdir(dir,{withFileTypes:true})){if(item.name==='__pycache__')continue;const p=path.join(dir,item.name);if(item.isDirectory())await hashTree(p);else files[path.relative(installed,p)]=createHash('sha256').update(await fs.readFile(p)).digest('hex');}}
72
+ await hashTree(installed);
73
+ const receipt={kind:'private-workflow-development',created_at:new Date().toISOString(),root,workspace,inbox,server,files,client_profile_changed:false,account_api_calls:0,dependencies:run(executable,['-m','pip','freeze']).trim().split('\n')};
74
+ await fs.writeFile(path.join(root,'installation.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});
75
+ await fs.writeFile(path.join(root,'Read me.txt'),'TasksAI private Mac trial\n\nUse fictional documents only. Put the selected PDF, Word or text files in Inbox. Double-click Start TasksAI.command to open an interactive Codex session using your existing ChatGPT sign-in. Ask it to compare the offers. You do not need to write structured data.\n\nSaved jobs contains the original documents, versioned Word/Excel packages and local job database. Keep this entire folder to preserve and resume work. Each launch starts a new conversation; ask to reopen your saved matter.\n\nText-bearing documents only; scanned images and password-protected PDFs need accessible copies. Check extracted text against originals. This trial does not send, accept or professionally approve offers. Excel edits are scenarios; ask the workflow to revise the job to update both files.\n\nThis installation is separate from customer installations and does not change any global AI app configuration. Using the AI session consumes your existing ChatGPT allowance. To stop using it, close the session; retain the folder for your saved work.\n',{mode:0o600});
76
+ await fs.unlink(marker);
77
+ console.log(JSON.stringify({status:'private_installation_complete',root,launcher:path.join(root,'Start TasksAI.command'),client_profile_changed:false}));
78
+ return receipt;
79
+ }
80
+
81
+ async function updatePrivateWorkflow(root){
82
+ const receiptPath=path.join(root,'installation.json');
83
+ const receipt=JSON.parse(await fs.readFile(receiptPath,'utf8'));
84
+ if(receipt.kind!=='private-workflow-development'||receipt.root!==root)throw new Error('Select an existing private workflow installation.');
85
+ if((await fs.readFile(path.join(root,'requirements.txt'),'utf8'))!==(await fs.readFile(path.join(runtime,'workflow-requirements.txt'),'utf8')))throw new Error('Dependencies changed; create a fresh private installation.');
86
+ const installed=path.join(root,'runtime');
87
+ for(const [file,expected] of Object.entries(receipt.files)){
88
+ const full=path.resolve(installed,file);
89
+ if(!full.startsWith(installed+path.sep))throw new Error('Invalid installed file path.');
90
+ const actual=createHash('sha256').update(await fs.readFile(full)).digest('hex');
91
+ if(actual!==expected)throw new Error('Installed code changed outside this installer. Preserve and inspect it before updating.');
92
+ }
93
+ const suffix=randomUUID();const next=path.join(root,'runtime-next-'+suffix);
94
+ await fs.mkdir(next,{mode:0o700});
95
+ await fs.cp(path.join(runtime,'workflows'),path.join(next,'workflows'),{recursive:true,filter:s=>!s.includes('__pycache__')&&!s.endsWith('.pyc')});
96
+ await fs.copyFile(path.join(runtime,'document_renderer.py'),path.join(next,'document_renderer.py'));
97
+ const {mergeReleaseHistory}=await import('./workspace-runtime.js');
98
+ const registryName='realtor-release-registry.json';
99
+ let priorRegistry;
100
+ try{priorRegistry=JSON.parse(await fs.readFile(path.join(installed,'workflows',registryName),'utf8'));}catch(error){if(error.code!=='ENOENT')throw error;}
101
+ const nextRegistry=JSON.parse(await fs.readFile(path.join(next,'workflows',registryName),'utf8'));
102
+ await fs.writeFile(path.join(next,'workflows',registryName),JSON.stringify(mergeReleaseHistory(priorRegistry,nextRegistry),null,2)+'\n');
103
+
104
+ run(path.join(root,'python/bin/python'),['-c','from workflows.gateway import TOOLS; from workflows.realtor.adapter import SellerOfferAdapter; assert len(TOOLS)==13'],{cwd:next,env:{...process.env,PYTHONPATH:next}});
105
+ const files={};
106
+ async function hashTree(dir){for(const item of await fs.readdir(dir,{withFileTypes:true})){if(item.name==='__pycache__')continue;const p=path.join(dir,item.name);if(item.isDirectory())await hashTree(p);else files[path.relative(next,p)]=createHash('sha256').update(await fs.readFile(p)).digest('hex');}}
107
+ await hashTree(next);
108
+ const previous=path.join(root,'Previous implementations',suffix);await fs.mkdir(previous,{recursive:true,mode:0o700});
109
+ await fs.copyFile(receiptPath,path.join(previous,'installation.json'));
110
+ await fs.rename(installed,path.join(previous,'runtime'));
111
+ try{await fs.rename(next,installed);}catch(error){await fs.rename(path.join(previous,'runtime'),installed);throw error;}
112
+ const updated={...receipt,files,updated_at:new Date().toISOString(),previous_implementation:previous};
113
+ const temp=receiptPath+'.next';await fs.writeFile(temp,JSON.stringify(updated,null,2)+'\n',{mode:0o600});await fs.rename(temp,receiptPath);
114
+ console.log(JSON.stringify({status:'private_implementation_updated',root,previous,workspace_preserved:true}));
115
+ return updated;
116
+ }
@@ -0,0 +1,50 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ import path from 'node:path';
3
+ import {managedPythonLocation,probeManagedPython,installManagedPython} from './managed-python.js';
4
+
5
+ const probe='import json,sys; print(json.dumps({"version":list(sys.version_info[:3]),"executable":sys.executable}))';
6
+
7
+ export function detectPython({platform=process.platform,run=spawnSync}={}){
8
+ const candidates=platform==='win32'?[['python',[]],['python3',[]],['py',['-3']]]:[['python3',[]],['python',[]]];
9
+ const paths=platform==='win32'?path.win32:path.posix;
10
+ for(const [command,prefix] of candidates){
11
+ const result=run(command,[...prefix,'-I','-c',probe],{encoding:'utf8',timeout:5000,windowsHide:true,maxBuffer:16384});
12
+ if(result.error||result.status!==0)continue;
13
+ try{
14
+ const info=JSON.parse(result.stdout);
15
+ if(!Array.isArray(info.version)||info.version[0]!==3||!Number.isInteger(info.version[1])||info.version[1]<10)continue;
16
+ if(typeof info.executable!=='string'||!paths.isAbsolute(info.executable)||/[\r\n\0]/.test(info.executable))continue;
17
+ return info.executable;
18
+ }catch{}
19
+ }
20
+ return null;
21
+ }
22
+
23
+ // One interpreter per installer process: dependency installation, client setup,
24
+ // health checks and software-use coordination must not choose independently.
25
+ let selected;
26
+ let installRoot;
27
+ export function setPythonInstallRoot(root){
28
+ if(installRoot!==root){installRoot=root;selected=undefined;}
29
+ }
30
+ function availablePython(){
31
+ if(installRoot){
32
+ const {executable}=managedPythonLocation(installRoot);
33
+ if(probeManagedPython(executable))return executable;
34
+ }
35
+ return detectPython();
36
+ }
37
+ export async function ensurePython(installDir){
38
+ setPythonInstallRoot(installDir);
39
+ selected??=availablePython();
40
+ if(!selected){
41
+ console.log('Installing the local Python needed by TasksAI. Your existing applications and project files are preserved.');
42
+ selected=await installManagedPython(installDir);
43
+ }
44
+ return selected;
45
+ }
46
+ export function requirePython(){
47
+ selected??=availablePython();
48
+ if(!selected)throw Error('TasksAI requires Python 3.10 or newer. No usable installation was found.');
49
+ return selected;
50
+ }
@@ -0,0 +1,30 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {planUpdateRecovery,restoreUpdate} from './update-recovery.js';
4
+
5
+ export async function recoverInstallation(installDir){
6
+ let entries;
7
+ try{entries=await fs.readdir(installDir,{withFileTypes:true});}
8
+ catch(error){if(error.code==='ENOENT')throw Error('No saved installation was found at '+installDir);throw error;}
9
+ const pending=[];
10
+ for(const entry of entries){
11
+ if(!entry.name.startsWith('.software-update-'))continue;
12
+ let complete=false;
13
+ if(entry.isDirectory()&&!entry.isSymbolicLink()){
14
+ try{
15
+ const file=path.join(installDir,entry.name,'state.json');
16
+ const stat=await fs.lstat(file);
17
+ if(stat.isFile()&&!stat.isSymbolicLink()&&stat.size<=4096)complete=['activated','recovered'].includes(JSON.parse(await fs.readFile(file,'utf8')).status);
18
+ }catch{}
19
+ }
20
+ if(!complete)pending.push(entry.name);
21
+ }
22
+ if(!pending.length){
23
+ if(entries.some(entry=>['.installer-operation.lock','.installer-recovery.lock'].includes(entry.name)))throw Error('An installation lock remains without a supported unfinished transaction. Preserve the installation for recovery review.');
24
+ return {status:'not_needed'};
25
+ }
26
+ if(pending.length!==1)throw Error('More than one unfinished update needs review. Preserve all recovery directories; no software was changed.');
27
+ // Establish a supported, unambiguous candidate before touching stale locks.
28
+ await planUpdateRecovery(installDir,pending[0]);
29
+ return restoreUpdate(installDir,pending[0],{recoverInterruptedLock:true});
30
+ }
@@ -0,0 +1,30 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {withLock} from './operation-lock.js';
5
+
6
+ export async function withRecoveryLock(installDir,callback){
7
+ // Serialize recovery attempts. A crash of recovery itself remains fail-closed;
8
+ // do not recursively clear locks whose ownership cannot be established.
9
+ return withLock(path.join(installDir,'.installer-recovery.lock'),async()=>{
10
+ const lock=path.join(installDir,'.installer-operation.lock');
11
+ let stat;
12
+ try{stat=await fs.lstat(lock);}catch(error){if(error.code!=='ENOENT')throw error;}
13
+ if(stat){
14
+ if(!stat.isFile()||stat.isSymbolicLink()||stat.size>4096)throw Error('Cannot establish interrupted installer ownership');
15
+ const text=await fs.readFile(lock,'utf8');
16
+ let owner;
17
+ try{owner=JSON.parse(text);}catch{throw Error('Cannot establish interrupted installer ownership');}
18
+ if(!owner||!Number.isSafeInteger(owner.pid)||owner.pid<=0)throw Error('Cannot establish interrupted installer ownership');
19
+ try{
20
+ process.kill(owner.pid,0);
21
+ throw Error('The recorded installer process is still running; recovery has not started');
22
+ }catch(error){if(error.code!=='ESRCH')throw error;}
23
+ const current=await fs.lstat(lock);
24
+ if(current.ino!==stat.ino||current.isSymbolicLink()||await fs.readFile(lock,'utf8')!==text)throw Error('Installer ownership changed during recovery inspection');
25
+ await fs.rename(lock,path.join(installDir,'.interrupted-operation-'+randomUUID()+'.json'));
26
+ }
27
+ // A competing normal installer may acquire this first; in that case stop.
28
+ return withLock(lock,callback);
29
+ });
30
+ }
@@ -0,0 +1,24 @@
1
+ import {promises as fs,createReadStream} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+
5
+ // Hash software only. Never follow links into a workspace or another directory.
6
+ export async function softwareHash(target){
7
+ try{await fs.lstat(target);}catch(error){if(error.code==='ENOENT')return null;throw error;}
8
+ const hash=createHash('sha256');let count=0;
9
+ async function visit(file,relative,depth){
10
+ if(++count>100000||depth>128)throw Error('Software tree exceeds recovery inspection limits');
11
+ const stat=await fs.lstat(file);
12
+ if(stat.isSymbolicLink()||(!stat.isFile()&&!stat.isDirectory()))throw Error('Software recovery cannot inspect links or special files');
13
+ hash.update(JSON.stringify([relative,stat.isDirectory()?'directory':'file'])+'\n');
14
+ if(stat.isDirectory()){
15
+ for(const name of (await fs.readdir(file)).sort())await visit(path.join(file,name),relative?relative+'/'+name:name,depth+1);
16
+ }else{
17
+ hash.update(String(stat.size)+'\n');
18
+ for await(const chunk of createReadStream(file))hash.update(chunk);
19
+ const after=await fs.lstat(file);
20
+ if(after.size!==stat.size||after.mtimeMs!==stat.mtimeMs||after.ino!==stat.ino)throw Error('Software changed during recovery inspection');
21
+ }
22
+ }
23
+ await visit(target,'',0);return hash.digest('hex');
24
+ }
@@ -0,0 +1,32 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import {spawn} from 'node:child_process';
3
+ import {requirePython} from './python-runtime.js';
4
+
5
+ // Use the same standard-library OS lock as Python services. The child retains
6
+ // the exclusive lock until stdin closes, including when the Node parent exits.
7
+ export async function withSoftwareStopped(installDir,callback){
8
+ const source=await fs.readFile(new URL('../runtime/software_use.py',import.meta.url),'utf8');
9
+ const program=source+`\nimport sys\ntry:\n with software_use(sys.argv[1], exclusive=True):\n print('READY', flush=True)\n sys.stdin.buffer.read()\nexcept (OSError, RuntimeError):\n sys.exit(2)\n`;
10
+ const child=spawn(requirePython(),['-I','-c',program,installDir],{stdio:['pipe','pipe','pipe']});
11
+ child.stdin.on('error',()=>{}); // The child may reject the lock before stdin closes.
12
+ const exited=new Promise(resolve=>{child.once('exit',resolve);child.once('error',()=>resolve(null));});
13
+ child.stderr.resume();
14
+ try{
15
+ await new Promise((resolve,reject)=>{
16
+ const timer=setTimeout(()=>reject(Error('Could not check whether TasksAI software is in use')),15000);
17
+ const fail=()=>{clearTimeout(timer);reject(Error('Close the TasksAI workspace and its AI-app connection before updating or recovering the software'));};
18
+ child.once('error',fail);child.once('exit',fail);
19
+ child.stdout.once('data',data=>{
20
+ clearTimeout(timer);
21
+ if(data.toString().trim()==='READY')resolve();else reject(Error('Invalid software-use response'));
22
+ });
23
+ });
24
+ const assertHeld=()=>{if(child.exitCode!==null||child.signalCode!==null)throw Error('Software-use coordination ended unexpectedly; preserve recovery files');};
25
+ assertHeld();
26
+ return await callback(assertHeld);
27
+ }finally{
28
+ child.stdin.end();
29
+ const timer=setTimeout(()=>child.kill(),5000);
30
+ await exited;clearTimeout(timer);
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+
5
+ // Replace the state record as a complete file, rather than truncating the last
6
+ // valid record. Windows directory flushing requires separate native coverage.
7
+ export async function writeUpdateJournal(transaction,state,{rename=fs.rename}={}){
8
+ const temporary=path.join(transaction,'.state-'+randomUUID());
9
+ let handle;
10
+ try{
11
+ handle=await fs.open(temporary,'wx',0o600);
12
+ await handle.writeFile(JSON.stringify(state)+'\n');
13
+ await handle.sync();
14
+ await handle.close();handle=null;
15
+ await rename(temporary,path.join(transaction,'state.json'));
16
+ if(process.platform!=='win32'){
17
+ const directory=await fs.open(transaction,'r');
18
+ try{await directory.sync();}finally{await directory.close();}
19
+ }
20
+ }finally{
21
+ if(handle)await handle.close();
22
+ await fs.rm(temporary,{force:true});
23
+ }
24
+ }
@@ -0,0 +1,72 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {softwareHash} from './software-hash.js';
4
+ import {writeUpdateJournal} from './update-journal.js';
5
+ import {withLock} from './operation-lock.js';
6
+ import {withRecoveryLock} from './recovery-lock.js';
7
+ import {withSoftwareStopped} from './software-use.js';
8
+
9
+ const SOFTWARE=['python','runtime','agent-install.json','vertical.json','.env'];
10
+ const digest=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
11
+
12
+ // Read-only planning. Callers must coordinate writers before executing a plan;
13
+ // a recorded phase alone never proves which files actually reached a location.
14
+ export async function planUpdateRecovery(installDir,transactionName){
15
+ if(!/^\.software-update-[A-Za-z0-9]+$/.test(transactionName))throw Error('Invalid recovery directory');
16
+ const transaction=path.join(installDir,transactionName);
17
+ for(const directory of [installDir,transaction,path.join(transaction,'prepared'),path.join(transaction,'previous')]){
18
+ const stat=await fs.lstat(directory);
19
+ if(!stat.isDirectory()||stat.isSymbolicLink())throw Error('Recovery directories must be ordinary local directories');
20
+ }
21
+ const statePath=path.join(transaction,'state.json'),stat=await fs.lstat(statePath);
22
+ if(!stat.isFile()||stat.isSymbolicLink()||stat.size>4096)throw Error('Invalid recovery record');
23
+ const state=JSON.parse(await fs.readFile(statePath,'utf8'));
24
+ if(state.format!==2||!['prepared','recovered'].includes(state.status)||!Array.isArray(state.names)||!Array.isArray(state.assets))throw Error('This update does not have a supported unfinished recovery record');
25
+ const expected=state.names.includes('python')?SOFTWARE:SOFTWARE.slice(1);
26
+ if(JSON.stringify(state.names)!==JSON.stringify(expected)||state.assets.length!==expected.length)throw Error('Invalid recovery component list');
27
+ const actions=[];
28
+ for(let i=0;i<expected.length;i++){
29
+ const asset=state.assets[i],name=expected[i];
30
+ if(asset.name!==name||typeof asset.hadPrevious!=='boolean'||!digest(asset.afterHash)||
31
+ (asset.hadPrevious?!digest(asset.beforeHash):asset.beforeHash!==null)||
32
+ !['pending','original_moved','replacement_moved'].includes(asset.phase))throw Error('Invalid recovery component evidence');
33
+ const active=path.join(installDir,name),previous=path.join(transaction,'previous',name),prepared=path.join(transaction,'prepared',name);
34
+ const a=await softwareHash(active),b=await softwareHash(previous),p=await softwareHash(prepared);
35
+ if(asset.hadPrevious&&b===null&&a===asset.beforeHash&&p===asset.afterHash)continue;
36
+ if(asset.hadPrevious&&b===asset.beforeHash){
37
+ if(a===null&&p===asset.afterHash){actions.unshift({name,moves:[{from:previous,to:active,hash:b}]});continue;}
38
+ if(a===asset.afterHash&&p===null){actions.unshift({name,moves:[{from:active,to:prepared,hash:a},{from:previous,to:active,hash:b}]});continue;}
39
+ }
40
+ if(!asset.hadPrevious&&b===null){
41
+ if(a===null&&p===asset.afterHash)continue;
42
+ if(a===asset.afterHash&&p===null){actions.unshift({name,moves:[{from:active,to:prepared,hash:a}]});continue;}
43
+ }
44
+ throw Error('Recovery files are missing, changed or ambiguous for '+name+'. Preserve all copies for review.');
45
+ }
46
+ if(state.status==='recovered'&&actions.length)throw Error('Recovered software has changed; preserve the recovery copies for review');
47
+ return {transaction,state,actions};
48
+ }
49
+
50
+ // Active workspace coordination and the customer command remain separate.
51
+ export async function restoreUpdate(installDir,transactionName,{rename=fs.rename,recoverInterruptedLock=false}={}){
52
+ const lock=callback=>recoverInterruptedLock?withRecoveryLock(installDir,callback):withLock(path.join(installDir,'.installer-operation.lock'),callback);
53
+ return lock(()=>withSoftwareStopped(installDir,async assertHeld=>{
54
+ const initial=await planUpdateRecovery(installDir,transactionName);
55
+ let moved=0;
56
+ for(const action of initial.actions){
57
+ for(const move of action.moves){
58
+ assertHeld();
59
+ if(await softwareHash(move.from)!==move.hash||await softwareHash(move.to)!==null)throw Error('Recovery files changed before restoration; preserve all copies');
60
+ await rename(move.from,move.to);moved++;
61
+ // The initial identities plus actual locations make a retry possible
62
+ // even when interruption occurs before this progress record is saved.
63
+ await writeUpdateJournal(initial.transaction,{...initial.state,recoveryMoves:moved});
64
+ }
65
+ }
66
+ assertHeld();
67
+ const verified=await planUpdateRecovery(installDir,transactionName);
68
+ if(verified.actions.length)throw Error('Software restoration is incomplete; preserve the recovery copies');
69
+ await writeUpdateJournal(verified.transaction,{...verified.state,status:'recovered'});
70
+ return {status:'recovered',transaction:verified.transaction,moved};
71
+ }));
72
+ }
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ import {promises as fs} from 'node:fs';
3
+
4
+ // Copy only release-owned workflow files. Customer projects live elsewhere.
5
+ export async function installWorkspaceRuntime(bundledRuntime, runtimeDir) {
6
+ const source=path.join(bundledRuntime,'workflows');
7
+ const destination=path.join(runtimeDir,'workflows');
8
+ const registryName='realtor-release-registry.json';
9
+ let previous;
10
+ try { previous=JSON.parse(await fs.readFile(path.join(destination,registryName),'utf8')); }
11
+ catch(error) { if(error.code!=='ENOENT') throw error; }
12
+ const merged=mergeReleaseHistory(previous,JSON.parse(await fs.readFile(path.join(source,registryName),'utf8')));
13
+
14
+ await fs.cp(source,destination,{recursive:true,force:true,dereference:false,
15
+ filter: async entry=>{
16
+ const stat=await fs.lstat(entry);
17
+ if(stat.isSymbolicLink()) throw new Error('Bundled workflow files must not be symbolic links');
18
+ const name=path.basename(entry);
19
+ return name!=='__pycache__' && !name.endsWith('.pyc') && !name.endsWith('.pyo');
20
+ }});
21
+ await fs.writeFile(path.join(destination,registryName),JSON.stringify(merged,null,2)+'\n','utf8');
22
+ const workflowRequirements=await fs.readFile(path.join(bundledRuntime,'workflow-requirements.txt'),'utf8');
23
+ await fs.writeFile(path.join(runtimeDir,'workflow-requirements.txt'),workflowRequirements,'utf8');
24
+ const requirements=path.join(runtimeDir,'requirements.txt');
25
+ const base=await fs.readFile(requirements,'utf8');
26
+ const include='-r workflow-requirements.txt';
27
+ if(!base.split(/\r?\n/).includes(include)) await fs.writeFile(requirements,base.trimEnd()+'\n'+include+'\n','utf8');
28
+ }
29
+
30
+ export function mergeReleaseHistory(previous, next) {
31
+ if (!previous) return next;
32
+ if(previous.product_id!=='realtor'||next.product_id!=='realtor') throw new Error('Invalid release registry product');
33
+ const merged=structuredClone(next);
34
+ for(const [id,old] of Object.entries(previous.skills)) {
35
+ if(!merged.skills[id]) throw new Error('An installed skill is missing from the update; preserve its migration before continuing');
36
+ const current=merged.skills[id];const versions=new Map();
37
+ for(const item of [current,...(current.history||[]),old,...(old.history||[])]) {
38
+ if(typeof item.version!=='string'||!/^[0-9a-f]{64}$/.test(item.content_sha256)) throw new Error('Invalid saved skill version');
39
+ if(versions.has(item.version)&&versions.get(item.version)!==item.content_sha256) throw new Error('An immutable skill version has conflicting content');
40
+ versions.set(item.version,item.content_sha256);
41
+ }
42
+ current.history=[...versions].filter(([version])=>version!==current.version).map(([version,content_sha256])=>({version,content_sha256}));
43
+ }
44
+ return merged;
45
+ }