@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,39 @@
1
+ """OS-held software-use lock; released by the OS if its process exits."""
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ import os
5
+
6
+
7
+ @contextmanager
8
+ def software_use(install_dir, *, exclusive=False):
9
+ lock = Path(install_dir) / '.software-use.lock'
10
+ if lock.is_symlink():
11
+ raise RuntimeError('Software-use lock cannot be a symbolic link')
12
+ with lock.open('a+b') as stream:
13
+ if os.name == 'nt':
14
+ import ctypes
15
+ from ctypes import wintypes
16
+ import msvcrt
17
+ class Overlapped(ctypes.Structure):
18
+ _fields_ = [('Internal',ctypes.c_size_t),('InternalHigh',ctypes.c_size_t),
19
+ ('Offset',wintypes.DWORD),('OffsetHigh',wintypes.DWORD),('hEvent',wintypes.HANDLE)]
20
+ kernel = ctypes.WinDLL('kernel32',use_last_error=True)
21
+ kernel.LockFileEx.argtypes = [wintypes.HANDLE,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(Overlapped)]
22
+ kernel.LockFileEx.restype = wintypes.BOOL
23
+ kernel.UnlockFileEx.argtypes = [wintypes.HANDLE,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(Overlapped)]
24
+ kernel.UnlockFileEx.restype = wintypes.BOOL
25
+ handle = msvcrt.get_osfhandle(stream.fileno())
26
+ overlap = Overlapped()
27
+ if not kernel.LockFileEx(handle,1 | (2 if exclusive else 0),0,1,0,ctypes.byref(overlap)):
28
+ raise ctypes.WinError(ctypes.get_last_error())
29
+ else:
30
+ import fcntl
31
+ fcntl.flock(stream.fileno(), (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
32
+ try:
33
+ yield
34
+ finally:
35
+ if os.name == 'nt':
36
+ if not kernel.UnlockFileEx(handle,0,1,0,ctypes.byref(overlap)):
37
+ raise ctypes.WinError(ctypes.get_last_error())
38
+ else:
39
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
@@ -0,0 +1,5 @@
1
+ mcp==1.28.1
2
+ jsonschema==4.25.1
3
+ python-docx==1.2.0
4
+ pypdf==6.10.0
5
+ XlsxWriter==3.2.9
File without changes
@@ -0,0 +1,33 @@
1
+ """Local display-only credit receipt. Never stores credentials or calls an API."""
2
+ from datetime import datetime,timezone
3
+ import json
4
+ import os
5
+ import uuid
6
+ from pathlib import Path
7
+ from .licensed_delivery import safe_directory
8
+ from .store import JobError
9
+
10
+
11
+ def read(root):
12
+ path=Path(root)/'account-display.json'
13
+ if path.is_symlink():raise JobError('Account display must not be a symbolic link')
14
+ if not path.exists():return {'credits':None,'updated_at':None,'license_type':None}
15
+ data=json.loads(path.read_text(encoding="utf-8"))
16
+ return {key:data.get(key) for key in ('credits','updated_at','license_type')}
17
+
18
+
19
+ def remember(root,result):
20
+ credits=result.get('credits_balance',result.get('credits_remaining'))
21
+ if isinstance(credits,bool) or not isinstance(credits,int) or credits<0:return
22
+ root=safe_directory(root);prior=read(root)
23
+ kind=result.get('license_type',prior.get('license_type'))
24
+ if not isinstance(kind,str) or len(kind)>80:kind=None
25
+ data={'credits':credits,'updated_at':datetime.now(timezone.utc).isoformat(),'license_type':kind}
26
+ temporary=root/('account-display-'+uuid.uuid4().hex+'.tmp')
27
+ descriptor=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
28
+ try:
29
+ with os.fdopen(descriptor,'w',encoding='utf-8') as stream:
30
+ json.dump(data,stream);stream.flush();os.fsync(stream.fileno())
31
+ temporary.replace(root/'account-display.json')
32
+ finally:
33
+ temporary.unlink(missing_ok=True)
@@ -0,0 +1,45 @@
1
+ """Private-trial uploads backed by the existing immutable document store."""
2
+ import base64
3
+ import binascii
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+ from .documents import Documents, EXTENSIONS
7
+ from .store import JobError, label
8
+
9
+ MAX_UPLOAD = 2_000_000
10
+
11
+ def upload(demo, job_id, filename, encoded, request_id):
12
+ label(request_id, 'request_id')
13
+ label(filename, 'filename')
14
+ job = demo.store.read(job_id)
15
+ if not job['current'] or job['current']['input'].get('example') is not True:
16
+ raise JobError('Only fictional projects are available in this trial')
17
+ if Path(filename).name != filename or '/' in filename or '\\' in filename or filename in ('.', '..'):
18
+ raise JobError('Choose a file by its filename, not a computer path')
19
+ if Path(filename).suffix.lower() not in EXTENSIONS:
20
+ raise JobError('Choose a PDF, Word document, or text file')
21
+ if not isinstance(encoded, str) or len(encoded) > 4 * ((MAX_UPLOAD + 2) // 3):
22
+ raise JobError('This trial accepts files up to 2 MB')
23
+ try:
24
+ raw = base64.b64decode(encoded, validate=True)
25
+ except (ValueError, binascii.Error):
26
+ raise JobError('The uploaded file could not be read; choose it again')
27
+ if not raw or len(raw) > MAX_UPLOAD:
28
+ raise JobError('Choose a nonempty file up to 2 MB')
29
+ # Isolated staging prevents same-name uploads from overwriting one another.
30
+ # Documents captures the original and extracted text before staging is removed.
31
+ with TemporaryDirectory(prefix='upload-', dir=demo.root) as temporary:
32
+ inbox = Path(temporary)
33
+ selected = inbox / filename
34
+ selected.write_bytes(raw); selected.chmod(0o600)
35
+ result = Documents(demo.store, inbox).import_document(job_id, filename, request_id, True)
36
+ view = demo.view(job_id)
37
+ view['notice'] = 'Document saved to this project. It is available to reuse; your generated documents have not changed.'
38
+ view['uploaded_document_id'] = result['document_id']
39
+ return view
40
+
41
+
42
+ def read(demo, job_id, document_id):
43
+ result = demo.gateway.documents.read(job_id, document_id)
44
+ # Never expose filesystem paths or storage implementation to the host/card.
45
+ return {key: result[key] for key in ('id', 'filename', 'source_id', 'created_at', 'parts', 'warnings')}
@@ -0,0 +1,81 @@
1
+ """Immutable local copies of loader source metadata, not fetched source contents."""
2
+ from datetime import date, datetime, timezone
3
+ from hashlib import sha256
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import uuid
9
+ from .licensed_delivery import safe_directory
10
+ from .store import JobError, canonical
11
+
12
+ PACK_FIELDS = ('status','coverage_level','state_name','state_code','county','locality','district','mls',
13
+ 'registry_reviewed_on','registry_refresh_due_on','resolution_source')
14
+ SOURCE_FIELDS = ('title','authority','jurisdiction','scope_type','effective_date','verification_status',
15
+ 'reviewed_on','refresh_due_on','url','purpose','limits')
16
+ LIST_FIELDS = ('authority_layers','required_user_authorities','instructions')
17
+
18
+
19
+ def _text(value):
20
+ if value is None:return None
21
+ if not isinstance(value,str) or len(value)>10000:raise JobError('Invalid source guide metadata')
22
+ return value
23
+
24
+
25
+ def remember(root,skill_id,result):
26
+ if not re.fullmatch(r'realtor_[a-z0-9_]+',skill_id) or result.get('skill_id')!=skill_id:
27
+ raise JobError('Source guide does not match the requested skill')
28
+ packet={'skill_id':skill_id,**{k:_text(result.get(k)) for k in PACK_FIELDS}}
29
+ for key in LIST_FIELDS:
30
+ values=result.get(key) or []
31
+ if not isinstance(values,list) or len(values)>100:raise JobError('Source guide is too large')
32
+ packet[key]=[_text(v) for v in values]
33
+ sources=result.get('sources') or []
34
+ if not isinstance(sources,list) or len(sources)>100:raise JobError('Source guide is too large')
35
+ packet['sources']=[{k:_text(source.get(k)) for k in SOURCE_FIELDS} for source in sources]
36
+ encoded=canonical(packet).encode('utf-8')
37
+ if len(encoded)>200000:raise JobError('Source guide is too large')
38
+ identity=sha256(encoded).hexdigest();folder=safe_directory(Path(root)/'authority-guides')
39
+ destination=folder/(identity+'.json')
40
+ if destination.is_symlink():raise JobError('Source guide path must not be a symbolic link')
41
+ if not destination.exists():
42
+ temporary=folder/(uuid.uuid4().hex+'.tmp')
43
+ descriptor=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
44
+ try:
45
+ with os.fdopen(descriptor,'w',encoding='utf-8',newline='\n') as stream:
46
+ json.dump({'id':identity,'captured_at':datetime.now(timezone.utc).isoformat(),'packet':packet},stream)
47
+ stream.flush();os.fsync(stream.fileno())
48
+ # Exclusive destination creation preserves an existing immutable receipt.
49
+ try:
50
+ os.link(temporary,destination)
51
+ except FileExistsError:pass
52
+ finally:temporary.unlink(missing_ok=True)
53
+ return read(root,identity)
54
+
55
+
56
+ def read(root,identity,today=None):
57
+ if not isinstance(identity,str) or not re.fullmatch(r'[a-f0-9]{64}',identity):raise JobError('Invalid source guide ID')
58
+ folder=Path(root)/'authority-guides';path=folder/(identity+'.json')
59
+ if folder.is_symlink() or path.is_symlink():raise JobError('Source guide path must not be a symbolic link')
60
+ record=json.loads(path.read_text(encoding='utf-8'));packet=record['packet']
61
+ if record.get('id')!=identity or sha256(canonical(packet).encode('utf-8')).hexdigest()!=identity:
62
+ raise JobError('Saved source guide changed')
63
+ today=today or date.today()
64
+ deadlines=[packet.get('registry_refresh_due_on')]+[s.get('refresh_due_on') for s in packet['sources']]
65
+ try:refresh_needed=any(not value or date.fromisoformat(value)<=today for value in deadlines)
66
+ except (ValueError,TypeError):refresh_needed=True
67
+ return {**record,'refresh_needed':refresh_needed,'metadata_only':True,'source_text_available':False}
68
+
69
+
70
+ def for_skill(root,skill_id):
71
+ folder=Path(root)/'authority-guides'
72
+ if folder.is_symlink():raise JobError('Source guide path must not be a symbolic link')
73
+ if not folder.exists():return {'guides':[],'unavailable':0}
74
+ guides=[];unavailable=0
75
+ for path in folder.glob('*.json'):
76
+ try:
77
+ guide=read(root,path.stem)
78
+ if guide['packet']['skill_id']==skill_id:guides.append(guide)
79
+ except (JobError,OSError,ValueError,KeyError,TypeError):unavailable+=1
80
+ guides.sort(key=lambda guide:guide.get('captured_at',''),reverse=True)
81
+ return {'guides':guides,'unavailable':unavailable}
@@ -0,0 +1,51 @@
1
+ """Attach local presentation to an already selected and delivered skill.
2
+
3
+ No catalog search, API calls, billing, source rewriting, or content generation.
4
+ Bindings are application configuration, never user answers or uploaded files.
5
+ """
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+
9
+ from .store import JobError
10
+ from .template import InputField, OutputFile, WorkflowTemplate
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CatalogBinding:
15
+ product_id: str
16
+ skill_id: str
17
+ content_sha256: str
18
+ title: str
19
+ inputs: tuple[InputField, ...]
20
+ input_evidence: tuple[str, ...]
21
+ outputs: tuple[OutputFile, ...]
22
+ no_input_evidence: str = ''
23
+
24
+ def attach(self, *, product_id, skill_id, content):
25
+ """Fail closed on different delivered content; retain every instruction.
26
+
27
+ Exact quotes anchor field mappings to the source but do not establish
28
+ that a mapping is semantically correct. Developers must check optional
29
+ and conditional inputs before installing a binding.
30
+ """
31
+ if (product_id, skill_id) != (self.product_id, self.skill_id):
32
+ raise JobError('Presentation does not match the selected skill')
33
+ if not isinstance(content, str) or not content.strip() or sha256(content.encode()).hexdigest() != self.content_sha256:
34
+ raise JobError('Skill content changed; its presentation needs updating')
35
+ if len(self.inputs) != len(self.input_evidence):
36
+ raise JobError('Each input needs its original instruction')
37
+ evidence = self.input_evidence if self.inputs else (self.no_input_evidence,)
38
+ if any(not quote.strip() or quote not in content for quote in evidence):
39
+ raise JobError('Input mapping needs evidence from the selected skill')
40
+ template = WorkflowTemplate(self.skill_id, self.product_id, self.title, self.inputs, self.outputs)
41
+ return AttachedSkill(template, content)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class AttachedSkill:
46
+ template: WorkflowTemplate
47
+ instructions: str
48
+
49
+ def intake(self, answers=None):
50
+ # Partial answers are allowed; missing fields remain visible.
51
+ return self.template.input_state(answers)
@@ -0,0 +1,174 @@
1
+ """Private-loopback customer workspace. No public HTTP routes or account secrets."""
2
+ import argparse
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ from pathlib import Path
7
+ import secrets
8
+ import socket
9
+ import tempfile
10
+ import uuid
11
+ from starlette.applications import Starlette
12
+ from starlette.responses import HTMLResponse,JSONResponse,PlainTextResponse,FileResponse
13
+ from starlette.routing import Route
14
+ from starlette.background import BackgroundTask
15
+ from .catalog_workspace import CatalogWorkspace
16
+ from .documents import Documents,EXTENSIONS,MAX_BYTES
17
+ from .folders import Folders
18
+ from .released_catalog import registry
19
+ from .store import JobError
20
+ from .workspace_launch import runtime_fingerprint
21
+
22
+ ASSETS=Path(__file__).parent/'embedded'
23
+ MAX_ENCODED_BYTES=4*((MAX_BYTES+2)//3)
24
+ MAX_REQUEST_BYTES=MAX_ENCODED_BYTES+64_000
25
+
26
+
27
+ async def bounded_json(request):
28
+ declared=request.headers.get('content-length')
29
+ if declared is not None:
30
+ try:size=int(declared)
31
+ except ValueError:raise JobError('Invalid request size')
32
+ if size<0 or size>MAX_REQUEST_BYTES:raise JobError('Input too large; choose a smaller document')
33
+ body=bytearray()
34
+ async for chunk in request.stream():
35
+ if len(body)+len(chunk)>MAX_REQUEST_BYTES:raise JobError('Input too large; choose a smaller document')
36
+ body.extend(chunk)
37
+ return json.loads(body)
38
+
39
+ def app(workspace,token,*,on_shutdown=None):
40
+ active_actions=0
41
+ closing=False
42
+ prefix='/workspace/'+token
43
+ started_fingerprint=runtime_fingerprint()
44
+ page_html=(ASSETS/'catalog.html').read_text(encoding="utf-8").replace('__UPLOAD_LIMIT_BYTES__',str(MAX_BYTES)).replace('__UPLOAD_LIMIT_MB__',str(MAX_BYTES//1_000_000))
45
+ def local(request): return request.url.hostname in ('127.0.0.1','localhost')
46
+ async def health(request):
47
+ if not local(request):return PlainTextResponse('Not found',404)
48
+ from .workspace_launch import workspace_identity
49
+ return JSONResponse({'workspace_id':workspace_identity(workspace.root,workspace.store.owner),'runtime_fingerprint':started_fingerprint,'closing':closing})
50
+ async def page(request):
51
+ if not local(request):return PlainTextResponse('Not found',404)
52
+ return HTMLResponse(page_html,headers={'Cache-Control':'no-store','Content-Security-Policy':"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"})
53
+ async def action(request):
54
+ nonlocal active_actions
55
+ if not local(request) or request.headers.get('origin')!=str(request.base_url).rstrip('/') or request.headers.get('content-type')!='application/json':
56
+ return PlainTextResponse('Not found',404)
57
+ if closing:return JSONResponse({'error':'Workspace is closing. Reopen it from your AI assistant.'},status_code=409)
58
+ active_actions+=1
59
+ try:
60
+ if runtime_fingerprint()!=started_fingerprint:
61
+ return JSONResponse({'error':'TasksAI has been updated. Reopen the workspace from your AI assistant to use the updated version. Your saved projects and documents are still available.'},status_code=409)
62
+ payload=await bounded_json(request);name=payload['action'];job_id=payload.get('job_id')
63
+ if name=='account':
64
+ from .account_snapshot import read
65
+ result=read(workspace.root)
66
+ elif name=='projects': result=workspace.projects(50,payload.get('offset',0))
67
+ elif name=='skills':
68
+ result={'skills':[]}
69
+ for skill_id,release in registry().items():
70
+ path=workspace.root/'licensed-skills'/skill_id/release['content_sha256']/'instructions.md'
71
+ if path.is_file(): result['skills'].append({'skill_id':skill_id,**release})
72
+ elif name=='create':result=workspace.create(payload['skill_id'],payload['version'],payload['content_hash'],payload['matter'],payload['request_id'])
73
+ elif name=='open':result=workspace.view(job_id)
74
+ elif name=='answers':result=workspace.save_answers(job_id,payload['answers'],payload['sequence'],payload.get('user_request'))
75
+ elif name=='documents':result={'documents':Documents(workspace.store,allow_customer_documents=True).list(job_id)}
76
+ elif name=='capture-source':
77
+ from .source_capture import capture
78
+ result=await asyncio.to_thread(capture,workspace,job_id,payload['guide_id'],payload['source_index'])
79
+ elif name=='select-authority':result=workspace.select_authority_guide(job_id,payload.get('guide_id'),payload['sequence'])
80
+ elif name=='authority-guides':
81
+ from .authority_guides import for_skill
82
+ result=for_skill(workspace.root,workspace.binding(job_id)['skill_id'])
83
+ elif name=='recommend-documents':
84
+ from .document_selection import recommend
85
+ result=await asyncio.to_thread(recommend,workspace,job_id)
86
+ elif name=='upload':
87
+ workspace.view(job_id);filename=payload['filename']
88
+ if not isinstance(filename,str) or Path(filename).name!=filename or '\\' in filename or Path(filename).suffix.lower() not in EXTENSIONS:raise JobError('Choose a PDF, Word or text document')
89
+ encoded=payload['encoded']
90
+ if not isinstance(encoded,str) or len(encoded)>MAX_ENCODED_BYTES:raise JobError(f'Choose a file up to {MAX_BYTES//1_000_000} MB')
91
+ raw=base64.b64decode(encoded,validate=True)
92
+ if not 0<len(raw)<=MAX_BYTES:raise JobError(f'Choose a file up to {MAX_BYTES//1_000_000} MB')
93
+ with tempfile.TemporaryDirectory(dir=workspace.root) as directory:
94
+ selected=Path(directory)/filename;selected.write_bytes(raw)
95
+ result=Documents(workspace.store,Path(directory),allow_customer_documents=True).import_document(job_id,filename,payload['request_id'],False)
96
+ elif name=='suggest':
97
+ if payload.get('consent') is not True:raise JobError('Confirm sending the selected document text to your AI assistant')
98
+ from .catalog_suggestions import suggest
99
+ result=await asyncio.to_thread(suggest,workspace,job_id,payload.get('document_ids',[]))
100
+ elif name=='apply-suggestion':
101
+ from .catalog_suggestions import apply
102
+ result=apply(workspace,job_id,payload['suggestion_id'],payload['candidate_id'],payload['sequence'])
103
+ elif name=='generate':
104
+ if payload.get('consent') is not True:raise JobError('Confirm sending the saved answers and selected document text to your AI assistant')
105
+ from .catalog_generation import generate
106
+ result=await asyncio.to_thread(generate,workspace,job_id,payload['sequence'],payload.get('revision'),payload['request_id'],payload['request'],payload.get('document_ids',[]))
107
+ elif name=='review':
108
+ if workspace.view(job_id)['needs_regeneration']:raise JobError('Saved answers are newer than the files. Generate the updated documents before reviewing them.')
109
+ if payload.get('confirmed') is not True:raise JobError('Confirm that you reviewed this version')
110
+ ticket=workspace.store.request_review(job_id,payload['revision'],payload['request_id'],'Current catalog work product and supporting evidence')
111
+ workspace.store.record_local_review(ticket['id'],payload['reviewer'],'review_recorded',workspace.store.review_attestation(ticket,'review_recorded'),evidence_kind='card_attestation')
112
+ result=workspace.view(job_id)
113
+ elif name=='folders':result=Folders(workspace).settings(job_id)
114
+ elif name in ('input','output'):result=await asyncio.to_thread(Folders(workspace).select,job_id,name)
115
+ elif name=='scan':result=await asyncio.to_thread(Folders(workspace).scan,job_id)
116
+ elif name=='export':result=await asyncio.to_thread(Folders(workspace).save,job_id)
117
+ else:raise JobError('Unknown workspace action')
118
+ return JSONResponse(result,headers={'Cache-Control':'no-store'})
119
+ except (JobError,ValueError,KeyError,TypeError,OSError) as exc:
120
+ return JSONResponse({'error':str(exc)},status_code=400)
121
+ finally:
122
+ active_actions-=1
123
+ async def shutdown(request):
124
+ nonlocal closing
125
+ if not local(request) or request.headers.get('origin')!=str(request.base_url).rstrip('/') or request.headers.get('content-type')!='application/json':
126
+ return PlainTextResponse('Not found',404)
127
+ try:
128
+ payload=await bounded_json(request)
129
+ if payload.get('confirmed') is not True:raise JobError('Confirm closing this local workspace')
130
+ if active_actions:return JSONResponse({'error':'TasksAI is still working. Wait for the current action to finish before closing.'},status_code=409)
131
+ if closing:return JSONResponse({'status':'closing'})
132
+ if on_shutdown is None:raise JobError('This workspace cannot be closed from this screen')
133
+ draft=payload.get('draft')
134
+ if draft is not None:
135
+ if not isinstance(draft,dict):raise JobError('Invalid answers to save before closing')
136
+ workspace.save_answers(draft['job_id'],draft['answers'],draft['sequence'],draft.get('user_request'))
137
+ closing=True
138
+ return JSONResponse({'status':'closing'},background=BackgroundTask(on_shutdown))
139
+ except (JobError,ValueError,TypeError,AttributeError,KeyError) as exc:return JSONResponse({'error':str(exc)},status_code=400)
140
+ async def download(request):
141
+ if not local(request):return PlainTextResponse('Not found',404)
142
+ try:
143
+ job=workspace.store.read(request.path_params['job_id'])
144
+ if not job['current'] or request.query_params.get('revision')!=job['current_revision']:raise JobError('Reopen the project to get its current files')
145
+ names={'word':'work-product.docx','excel':'work-product.xlsx'};filename=names.get(request.path_params['kind'])
146
+ if not filename:raise JobError('Unknown document')
147
+ path=Path(job['current']['directory'])/'package'/filename
148
+ if not path.is_file():raise JobError('This project does not include that document')
149
+ return FileResponse(path,filename=filename,headers={'Cache-Control':'no-store'})
150
+ except JobError as exc:return PlainTextResponse(str(exc),400)
151
+ async def source(request):
152
+ if not local(request):return PlainTextResponse('Not found',404)
153
+ try:
154
+ document=Documents(workspace.store,allow_customer_documents=True).read(request.path_params['job_id'],request.path_params['document_id'])
155
+ return FileResponse(document['original_file'],filename=document['filename'],
156
+ headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
157
+ except (JobError,OSError,ValueError):
158
+ return PlainTextResponse('This saved source is unavailable. Reopen the project and add the original again.',400)
159
+ return Starlette(routes=[Route(prefix,page),Route(prefix+'/health',health),Route(prefix+'/shutdown',shutdown,methods=['POST']),Route(prefix+'/action',action,methods=['POST']),Route(prefix+'/file/{job_id}/{kind}',download),Route(prefix+'/source/{job_id}/{document_id}',source)])
160
+
161
+
162
+ def main():
163
+ parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--root',type=Path,required=True);parser.add_argument('--owner',required=True);parser.add_argument('--port',type=int,default=0);args=parser.parse_args()
164
+ workspace=CatalogWorkspace(args.root.resolve(),args.owner);token=secrets.token_urlsafe(32)
165
+ sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);sock.bind(('127.0.0.1',args.port));port=sock.getsockname()[1]
166
+ connection=workspace.root/'workspace-connection.json'
167
+ connection.write_text(json.dumps({'url':f'http://127.0.0.1:{port}/workspace/{token}'}), encoding="utf-8");connection.chmod(0o600)
168
+ import uvicorn
169
+ server=uvicorn.Server(uvicorn.Config(app(workspace,token,on_shutdown=lambda:setattr(server,'should_exit',True)),host='127.0.0.1',port=port,access_log=False))
170
+ server.run(sockets=[sock])
171
+ if __name__=='__main__':
172
+ from software_use import software_use
173
+ with software_use(Path(__file__).resolve().parents[2]):
174
+ main()